mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-19 21:09:25 +01:00
Compare commits
147 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 |
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
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
out
|
||||||
|
src/components/web
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
# .vscode/*
|
# .vscode/*
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@@ -7,13 +7,16 @@
|
|||||||
"changelogithub",
|
"changelogithub",
|
||||||
"clsx",
|
"clsx",
|
||||||
"codegen",
|
"codegen",
|
||||||
|
"dataurl",
|
||||||
"dtolnay",
|
"dtolnay",
|
||||||
"dyld",
|
"dyld",
|
||||||
"elif",
|
"elif",
|
||||||
|
"errmsg",
|
||||||
"fullscreen",
|
"fullscreen",
|
||||||
"headlessui",
|
"headlessui",
|
||||||
"Icdbb",
|
"Icdbb",
|
||||||
"icns",
|
"icns",
|
||||||
|
"iconfont",
|
||||||
"INFINI",
|
"INFINI",
|
||||||
"infinilabs",
|
"infinilabs",
|
||||||
"inputbox",
|
"inputbox",
|
||||||
@@ -56,6 +59,7 @@
|
|||||||
"uuidv",
|
"uuidv",
|
||||||
"VITE",
|
"VITE",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
|
"wavesurfer",
|
||||||
"webviews",
|
"webviews",
|
||||||
"xzvf",
|
"xzvf",
|
||||||
"yuque",
|
"yuque",
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -1,7 +1,15 @@
|
|||||||
# Coco AI - Connect & Collaborate
|
# Coco AI - Connect & Collaborate
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
**Tagline**: _"Coco AI - search, connect, collaborate – all in one place."_
|
**Tagline**: _"Coco AI - search, connect, collaborate – all in one place."_
|
||||||
|
|
||||||
|
Visit our website: [https://coco.rs](https://coco.rs)
|
||||||
|
|
||||||
|
[](LICENSE) [](https://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,
|
Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox,
|
||||||
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
|
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
|
||||||
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
|
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
|
||||||
@@ -12,16 +20,15 @@ and internal resources. Coco enhances collaboration by making information instan
|
|||||||
insights based on your enterprise's specific data.
|
insights based on your enterprise's specific data.
|
||||||
|
|
||||||
> **Note**: Backend services, including data indexing and search functionality, are handled in a
|
> **Note**: Backend services, including data indexing and search functionality, are handled in a
|
||||||
> separate [repository](https://github.com/infinilabs/coco-server).
|
separate [repository](https://github.com/infinilabs/coco-server).
|
||||||
|
|
||||||
## Vision
|

|
||||||
|
|
||||||
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco
|
## 🚀 Vision
|
||||||
App
|
|
||||||
provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their
|
|
||||||
workspace.
|
|
||||||
|
|
||||||
## Use Cases
|
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco App provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their workspace.
|
||||||
|
|
||||||
|
## 💡 Use Cases
|
||||||
|
|
||||||
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
|
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
|
||||||
conversations, and files across Google Workspace, Dropbox, GitHub, etc.
|
conversations, and files across Google Workspace, Dropbox, GitHub, etc.
|
||||||
@@ -32,37 +39,67 @@ workspace.
|
|||||||
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
|
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
|
||||||
productivity.
|
productivity.
|
||||||
|
|
||||||
## Getting Started
|
## ✨ Key Features
|
||||||
|
|
||||||
### Initial Setup
|
- 🔍 **Unified Search**: One-stop enterprise search with multi-platform integration
|
||||||
|
- Supports major collaboration platforms: Google Workspace, Dropbox, Confluence Wiki, GitHub, etc.
|
||||||
|
- Real-time search across documents, conversations, and files
|
||||||
|
- Smart search intent understanding with relevance ranking
|
||||||
|
- Cross-platform data correlation and context display
|
||||||
|
- 🤖 **AI-Powered Chat**: Team-specific ChatGPT-like assistant trained on your enterprise data
|
||||||
|
- 🌐 **Cross-Platform**: Available for Windows, macOS, Linux and Web
|
||||||
|
- 🔒 **Security-First**: Support for private deployment and data sovereignty
|
||||||
|
- ⚡ **High Performance**: Built with Rust and Tauri 2.0
|
||||||
|
- 🎨 **Modern UI**: Sleek interface designed for productivity
|
||||||
|
|
||||||
**This version of pnpm requires at least Node.js v18.12**
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
To set up the Coco App for development:
|
- **Frontend**: React + TypeScript
|
||||||
|
- **Desktop Framework**: Tauri 2.0
|
||||||
|
- **Styling**: Tailwind CSS
|
||||||
|
- **State Management**: Zustand
|
||||||
|
- **Build Tool**: Vite
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js >= 18.12
|
||||||
|
- Rust (latest stable)
|
||||||
|
- pnpm (package manager)
|
||||||
|
|
||||||
|
### Development Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd coco-app
|
# Install pnpm
|
||||||
npm install -g pnpm
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
pnpm tauri dev
|
pnpm tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Desktop Development:
|
### Production Build
|
||||||
|
|
||||||
To start desktop development, run:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm tauri dev
|
pnpm tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
For full documentation on Coco AI, please visit the [Coco AI Documentation](https://docs.infinilabs.com/coco-app/main/).
|
- [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
|
||||||
|
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
|
||||||
|
- [Tauri Documentation](https://tauri.app/)
|
||||||
|
|
||||||
## License
|
## 📄 License
|
||||||
|
|
||||||
Coco AI is an open-source project licensed under
|
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and
|
||||||
the [MIT License](https://github.com/infinilabs/coco-app/blob/main/LICENSE).
|
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
|
||||||
|
|
||||||
This means that you can freely use, modify, and
|
---
|
||||||
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
|
|
||||||
|
<div align="center">
|
||||||
|
Built with ❤️ by <a href="https://infinilabs.com">INFINI Labs</a>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ type: docs
|
|||||||
|
|
||||||
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
||||||
|
|
||||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
|
||||||
|
|
||||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ type: docs
|
|||||||
|
|
||||||
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
||||||
|
|
||||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
|
||||||
|
|
||||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,82 @@ Information about release notes of Coco Server is provided here.
|
|||||||
|
|
||||||
## Latest (In development)
|
## Latest (In development)
|
||||||
|
|
||||||
|
### ❌ Breaking changes
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
### 🐛 Bug fix
|
||||||
|
|
||||||
|
### ✈️ Improvements
|
||||||
|
|
||||||
|
## 0.4.0 (2025-04-27)
|
||||||
|
|
||||||
### Breaking changes
|
### Breaking changes
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: history support for searching, renaming and deleting #322
|
||||||
|
- feat: linux support for application search #330
|
||||||
|
- feat: add shortcuts to most icon buttons #334
|
||||||
|
- feat: add font icon for search list #342
|
||||||
|
- feat: add a border to the main window in Windows 10 #343
|
||||||
|
- feat: mobile terminal adaptation about style #348
|
||||||
|
- feat: service list popup box supports keyboard-only operation #359
|
||||||
|
- feat: networked search data sources support search and keyboard-only operation #367
|
||||||
|
- feat: add application management to the plugin #374
|
||||||
|
- feat: add keyboard-only operation to history list #385
|
||||||
|
- feat: add error notification #386
|
||||||
|
- feat: add support for AI assistant #394
|
||||||
|
- feat: add support for calculator function #399
|
||||||
|
- feat: auto selects the first item after searching #411
|
||||||
|
- feat: web components assistant #422
|
||||||
|
- feat: right-click menu support for search #423
|
||||||
|
- feat: add chat mode launch page #424
|
||||||
|
- feat: add MCP & call LLM tools #430
|
||||||
|
- feat: ai assistant supports search and paging #431
|
||||||
|
- feat: data sources support displaying customized icons #432
|
||||||
|
- feat: add shortcut key conflict hint and reset function #442
|
||||||
|
- feat: updated to include error message #465
|
||||||
|
|
||||||
|
### Bug fix
|
||||||
|
|
||||||
|
- fix: fixed the problem of not being able to search in secondary directories #338
|
||||||
|
- fix: active shadow setting #354
|
||||||
|
- fix: chat history was not show up #377
|
||||||
|
- fix: get attachments in chat sessions
|
||||||
|
- fix: filter http query_args and convert only supported values
|
||||||
|
- 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
|
### Bug fix
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
- refactor: refactor invoke related code #309
|
||||||
|
- refactor: hide apps without icon #312
|
||||||
|
|
||||||
## 0.2.1 (2025-03-14)
|
## 0.2.1 (2025-03-14)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -36,10 +104,10 @@ Information about release notes of Coco Server is provided here.
|
|||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
- Refactor: chat components #273
|
- Refactor: chat components #273
|
||||||
- Feat:add endpoint display #282
|
- Feat: add endpoint display #282
|
||||||
- Chore: chat window min width & remove input bg #284
|
- Chore: chat window min width & remove input bg #284
|
||||||
- Chore: remove selected function & add hide_coco #286
|
- Chore: remove selected function & add hide_coco #286
|
||||||
- Chore:websocket timeout increased to 2 minutes #289
|
- Chore: websocket timeout increased to 2 minutes #289
|
||||||
- Chore: remove chat input border & clear input #295
|
- Chore: remove chat input border & clear input #295
|
||||||
|
|
||||||
## 0.2.0 (2025-03-07)
|
## 0.2.0 (2025-03-07)
|
||||||
|
|||||||
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>
|
<title>Coco</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class="coco-container">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
35
package.json
35
package.json
@@ -1,11 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "coco",
|
"name": "coco",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.1",
|
"version": "0.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"build:web": "cross-env BUILD_TARGET=web tsc && cross-env BUILD_TARGET=web tsup --format esm",
|
||||||
|
"publish:web": "cd out/search-chat && npm publish",
|
||||||
|
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
|
||||||
|
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
|
||||||
|
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"release": "release-it",
|
"release": "release-it",
|
||||||
@@ -13,8 +18,9 @@
|
|||||||
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
|
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.0.0",
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@tauri-apps/api": "^2.3.0",
|
"@tauri-apps/api": "^2.4.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||||
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||||
@@ -23,19 +29,22 @@
|
|||||||
"@tauri-apps/plugin-os": "^2.2.1",
|
"@tauri-apps/plugin-os": "^2.2.1",
|
||||||
"@tauri-apps/plugin-process": "^2.2.0",
|
"@tauri-apps/plugin-process": "^2.2.0",
|
||||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.6.0",
|
"@tauri-apps/plugin-updater": "^2.6.1",
|
||||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||||
|
"@wavesurfer/react": "^1.0.9",
|
||||||
"ahooks": "^3.8.4",
|
"ahooks": "^3.8.4",
|
||||||
|
"axios": "^1.8.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"filesize": "^10.1.6",
|
"filesize": "^10.1.6",
|
||||||
"i18next": "^23.16.8",
|
"i18next": "^23.16.8",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lucide-react": "^0.461.0",
|
"lucide-react": "^0.461.0",
|
||||||
"mermaid": "^11.4.1",
|
"mermaid": "^11.5.0",
|
||||||
"nanoid": "^5.1.3",
|
"nanoid": "^5.1.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hotkeys-hook": "^4.6.1",
|
"react-hotkeys-hook": "^4.6.1",
|
||||||
@@ -48,30 +57,34 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"tauri-plugin-fs-pro-api": "^2.3.1",
|
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||||
"tauri-plugin-macos-permissions-api": "^2.1.1",
|
"tauri-plugin-macos-permissions-api": "^2.2.0",
|
||||||
"tauri-plugin-screenshots-api": "^2.1.0",
|
"tauri-plugin-screenshots-api": "^2.1.0",
|
||||||
|
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||||
"use-debounce": "^10.0.4",
|
"use-debounce": "^10.0.4",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
"wavesurfer.js": "^7.9.3",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.3.1",
|
"@tauri-apps/cli": "^2.4.0",
|
||||||
"@types/dom-speech-recognition": "^0.0.4",
|
"@types/dom-speech-recognition": "^0.0.4",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.11",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.19",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-i18next": "^8.1.0",
|
|
||||||
"@types/react-katex": "^3.0.4",
|
"@types/react-katex": "^3.0.4",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"release-it": "^18.1.2",
|
"release-it": "^18.1.2",
|
||||||
|
"sass": "^1.87.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsup": "^8.4.0",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.4.14"
|
"vite": "^5.4.14"
|
||||||
|
|||||||
1470
pnpm-lock.yaml
generated
1470
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
826
src-tauri/Cargo.lock
generated
826
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coco"
|
name = "coco"
|
||||||
version = "0.2.1"
|
version = "0.4.0"
|
||||||
description = "Search, connect, collaborate – all in one place."
|
description = "Search, connect, collaborate – all in one place."
|
||||||
authors = ["INFINI Labs"]
|
authors = ["INFINI Labs"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -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 = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
# Need `arbitrary_precision` feature to support storing u128
|
||||||
|
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||||
|
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||||
tauri-plugin-http = "2"
|
tauri-plugin-http = "2"
|
||||||
tauri-plugin-websocket = "2"
|
tauri-plugin-websocket = "2"
|
||||||
tauri-plugin-deep-link = "2.0.0"
|
tauri-plugin-deep-link = "2.0.0"
|
||||||
@@ -41,13 +43,13 @@ tauri-plugin-drag = "2"
|
|||||||
tauri-plugin-macos-permissions = "2"
|
tauri-plugin-macos-permissions = "2"
|
||||||
tauri-plugin-fs-pro = "2"
|
tauri-plugin-fs-pro = "2"
|
||||||
tauri-plugin-screenshots = "2"
|
tauri-plugin-screenshots = "2"
|
||||||
applications = "0.3.0"
|
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" }
|
||||||
|
|
||||||
tokio-native-tls = "0.3" # For wss connections
|
tokio-native-tls = "0.3" # For wss connections
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
|
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
|
||||||
hyper = { version = "0.14", features = ["client"] }
|
hyper = { version = "0.14", features = ["client"] }
|
||||||
reqwest = "0.12.12"
|
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
ordered-float = { version = "4.6.0", default-features = false }
|
ordered-float = { version = "4.6.0", default-features = false }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
@@ -68,6 +70,11 @@ url = "2.5.2"
|
|||||||
http = "1.1.0"
|
http = "1.1.0"
|
||||||
tungstenite = "0.24.0"
|
tungstenite = "0.24.0"
|
||||||
env_logger = "0.11.5"
|
env_logger = "0.11.5"
|
||||||
|
tokio-util = "0.7.14"
|
||||||
|
tauri-plugin-windows-version = "2"
|
||||||
|
meval = "0.2"
|
||||||
|
chinese-number = "0.7"
|
||||||
|
num2words = "1"
|
||||||
|
|
||||||
[target."cfg(target_os = \"macos\")".dependencies]
|
[target."cfg(target_os = \"macos\")".dependencies]
|
||||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -29,6 +29,7 @@
|
|||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:deny-internal-toggle-maximize",
|
"core:window:deny-internal-toggle-maximize",
|
||||||
|
"core:window:allow-set-shadow",
|
||||||
"core:app:allow-set-app-theme",
|
"core:app:allow-set-app-theme",
|
||||||
"shell:default",
|
"shell:default",
|
||||||
"http:default",
|
"http:default",
|
||||||
@@ -68,6 +69,7 @@
|
|||||||
"screenshots:default",
|
"screenshots:default",
|
||||||
"core:window:allow-set-theme",
|
"core:window:allow-set-theme",
|
||||||
"process:default",
|
"process:default",
|
||||||
"updater:default"
|
"updater:default",
|
||||||
|
"windows-version:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
use crate::common;
|
||||||
use crate::common::assistant::ChatRequestMessage;
|
use crate::common::assistant::ChatRequestMessage;
|
||||||
use crate::common::http::GetResponse;
|
use crate::common::http::GetResponse;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use reqwest::Response;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn chat_history<R: Runtime>(
|
pub async fn chat_history<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
from: u32,
|
from: u32,
|
||||||
size: u32,
|
size: u32,
|
||||||
|
query: Option<String>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut query_params: HashMap<String, Value> = HashMap::new();
|
let mut query_params: HashMap<String, Value> = HashMap::new();
|
||||||
if from > 0 {
|
if from > 0 {
|
||||||
@@ -21,30 +22,25 @@ pub async fn chat_history<R: Runtime>(
|
|||||||
query_params.insert("size".to_string(), size.into());
|
query_params.insert("size".to_string(), size.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(query) = query {
|
||||||
|
if !query.is_empty() {
|
||||||
|
query_params.insert("query".to_string(), query.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error get sessions: {}", e))?;
|
.map_err(|e| {
|
||||||
|
dbg!("Error get history: {}", &e);
|
||||||
|
format!("Error get history: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
common::http::get_response_body_text(response).await
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
|
|
||||||
Ok(
|
|
||||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
|
||||||
Err("Failed to send message".to_string())
|
|
||||||
} else {
|
|
||||||
let body = response
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
|
||||||
Ok(body)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn session_chat_history<R: Runtime>(
|
pub async fn session_chat_history<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
from: u32,
|
from: u32,
|
||||||
@@ -64,87 +60,90 @@ pub async fn session_chat_history<R: Runtime>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error get session message: {}", e))?;
|
.map_err(|e| format!("Error get session message: {}", e))?;
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
common::http::get_response_body_text(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_session_chat<R: Runtime>(
|
pub async fn open_session_chat<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut query_params = HashMap::new();
|
let query_params = HashMap::new();
|
||||||
let path = format!("/chat/{}/_open", session_id);
|
let path = format!("/chat/{}/_open", session_id);
|
||||||
|
|
||||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error open session: {}", e))?;
|
.map_err(|e| format!("Error open session: {}", e))?;
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
common::http::get_response_body_text(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn close_session_chat<R: Runtime>(
|
pub async fn close_session_chat<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut query_params = HashMap::new();
|
let query_params = HashMap::new();
|
||||||
let path = format!("/chat/{}/_close", session_id);
|
let path = format!("/chat/{}/_close", session_id);
|
||||||
|
|
||||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error close session: {}", e))?;
|
.map_err(|e| format!("Error close session: {}", e))?;
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
common::http::get_response_body_text(response).await
|
||||||
}
|
}
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn cancel_session_chat<R: Runtime>(
|
pub async fn cancel_session_chat<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let mut query_params = HashMap::new();
|
let query_params = HashMap::new();
|
||||||
let path = format!("/chat/{}/_cancel", session_id);
|
let path = format!("/chat/{}/_cancel", session_id);
|
||||||
|
|
||||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
common::http::get_response_body_text(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn new_chat<R: Runtime>(
|
pub async fn new_chat<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
|
websocket_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
query_params: Option<HashMap<String, Value>>,
|
||||||
) -> Result<GetResponse, String> {
|
) -> Result<GetResponse, String> {
|
||||||
let body = if !message.is_empty() {
|
let body = if !message.is_empty() {
|
||||||
let message = ChatRequestMessage {
|
let message = ChatRequestMessage {
|
||||||
message: Some(message),
|
message: Some(message),
|
||||||
};
|
};
|
||||||
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap());
|
Some(
|
||||||
Some(body)
|
serde_json::to_string(&message)
|
||||||
|
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body)
|
let mut headers = HashMap::new();
|
||||||
.await
|
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
|
||||||
|
|
||||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
let response =
|
||||||
return Err("Failed to send message".to_string());
|
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
|
||||||
}
|
.await
|
||||||
|
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||||
|
|
||||||
let chat_response: GetResponse = response
|
let body_text = common::http::get_response_body_text(response).await?;
|
||||||
.json()
|
|
||||||
.await
|
let chat_response: GetResponse =
|
||||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
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" {
|
if chat_response.result != "created" {
|
||||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||||
}
|
}
|
||||||
@@ -154,8 +153,9 @@ pub async fn new_chat<R: Runtime>(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn send_message<R: Runtime>(
|
pub async fn send_message<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
_app_handle: AppHandle<R>,
|
||||||
server_id: String,
|
server_id: String,
|
||||||
|
websocket_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||||
@@ -165,11 +165,94 @@ pub async fn send_message<R: Runtime>(
|
|||||||
message: Some(message),
|
message: Some(message),
|
||||||
};
|
};
|
||||||
|
|
||||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
let mut headers = HashMap::new();
|
||||||
let response =
|
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||||
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body))
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
|
||||||
|
|
||||||
handle_raw_response(response).await?
|
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||||
|
let response = HttpClient::advanced_post(
|
||||||
|
&server_id,
|
||||||
|
path.as_str(),
|
||||||
|
Some(headers),
|
||||||
|
query_params,
|
||||||
|
Some(body),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||||
|
|
||||||
|
common::http::get_response_body_text(response).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
|
||||||
|
let response =
|
||||||
|
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Err(format!("Delete failed with status: {}", response.status()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_session_chat(
|
||||||
|
server_id: String,
|
||||||
|
session_id: String,
|
||||||
|
title: Option<String>,
|
||||||
|
context: Option<HashMap<String, Value>>,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
let mut body = HashMap::new();
|
||||||
|
if let Some(title) = title {
|
||||||
|
body.insert("title".to_string(), Value::String(title));
|
||||||
|
}
|
||||||
|
if let Some(context) = context {
|
||||||
|
body.insert(
|
||||||
|
"context".to_string(),
|
||||||
|
Value::Object(context.into_iter().collect()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = HttpClient::put(
|
||||||
|
&server_id,
|
||||||
|
&format!("/chat/{}", session_id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error updating session: {}", e))?;
|
||||||
|
|
||||||
|
Ok(response.status().is_success())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn assistant_search<R: Runtime>(
|
||||||
|
_app_handle: AppHandle<R>,
|
||||||
|
server_id: String,
|
||||||
|
from: u32,
|
||||||
|
size: u32,
|
||||||
|
query: Option<HashMap<String, Value>>,
|
||||||
|
) -> Result<Value, String> {
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"from": from,
|
||||||
|
"size": size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(q) = query {
|
||||||
|
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = HttpClient::post(
|
||||||
|
&server_id,
|
||||||
|
"/assistant/_search",
|
||||||
|
None,
|
||||||
|
Some(reqwest::Body::from(body.to_string())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||||
|
|
||||||
|
response
|
||||||
|
.json::<Value>()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn change_autostart<R: Runtime>(app: tauri::AppHandle<R>, open: bool) -> Result<(), String> {
|
pub async fn change_autostart<R: Runtime>(
|
||||||
|
app: tauri::AppHandle<R>,
|
||||||
|
open: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct ChatRequestMessage {
|
|||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct NewChatResponse {
|
pub struct NewChatResponse {
|
||||||
pub _id: String,
|
pub _id: String,
|
||||||
pub _source: Source,
|
pub _source: Source,
|
||||||
@@ -22,4 +23,4 @@ pub struct Source {
|
|||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
pub manually_renamed_title: bool,
|
pub manually_renamed_title: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub struct DataSourceReference {
|
|||||||
pub r#type: Option<String>,
|
pub r#type: Option<String>,
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub id: Option<String>,
|
pub id: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -28,7 +29,7 @@ pub struct EditorInfo {
|
|||||||
pub timestamp: Option<String>,
|
pub timestamp: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct Document {
|
pub struct Document {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub created: Option<String>,
|
pub created: Option<String>,
|
||||||
@@ -54,8 +55,15 @@ pub struct Document {
|
|||||||
pub owner: Option<UserInfo>,
|
pub owner: Option<UserInfo>,
|
||||||
pub last_updated_by: Option<EditorInfo>,
|
pub last_updated_by: Option<EditorInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Document {
|
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 {
|
Self {
|
||||||
id,
|
id,
|
||||||
created: None,
|
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::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -15,4 +17,34 @@ pub struct Source {
|
|||||||
pub created: String,
|
pub created: String,
|
||||||
pub updated: String,
|
pub updated: String,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response body: {}", 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 register;
|
||||||
pub mod assistant;
|
pub mod assistant;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
|
pub mod error;
|
||||||
|
|
||||||
pub static MAIN_WINDOW_LABEL: &str = "main";
|
pub static MAIN_WINDOW_LABEL: &str = "main";
|
||||||
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
|
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
use serde::{Serialize, Deserialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
pub theme: String,
|
pub theme: Option<String>,
|
||||||
pub language: String,
|
pub language: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct UserProfile {
|
pub struct UserProfile {
|
||||||
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub avatar: String,
|
pub avatar: Option<String>,
|
||||||
pub preferences: Preferences,
|
pub preferences: Option<Preferences>,
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ impl SearchSourceRegistry {
|
|||||||
sources.insert(source_id, Arc::new(source));
|
sources.insert(source_id, Arc::new(source));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn clear(&self) {
|
pub async fn clear(&self) {
|
||||||
let mut sources = self.sources.write().await;
|
let mut sources = self.sources.write().await;
|
||||||
sources.clear();
|
sources.clear();
|
||||||
@@ -26,6 +27,7 @@ impl SearchSourceRegistry {
|
|||||||
sources.remove(id);
|
sources.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
|
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
|
||||||
let sources = self.sources.read().await;
|
let sources = self.sources.read().await;
|
||||||
sources.get(id).cloned()
|
sources.get(id).cloned()
|
||||||
@@ -34,4 +36,4 @@ impl SearchSourceRegistry {
|
|||||||
let sources = self.sources.read().await;
|
let sources = self.sources.read().await;
|
||||||
sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>>
|
sources.values().cloned().collect() // Returns Vec<Arc<dyn SearchSource>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use crate::common::document::Document;
|
use crate::common::document::Document;
|
||||||
|
use crate::common::http::get_response_body_text;
|
||||||
use reqwest::Response;
|
use reqwest::Response;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct SearchResponse<T> {
|
pub struct SearchResponse<T> {
|
||||||
pub took: u64,
|
pub took: u64,
|
||||||
pub timed_out: bool,
|
pub timed_out: bool,
|
||||||
pub _shards: Shards,
|
pub _shards: Option<Shards>,
|
||||||
pub hits: Hits<T>,
|
pub hits: Hits<T>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,14 +48,11 @@ pub async fn parse_search_response<T>(
|
|||||||
where
|
where
|
||||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||||
{
|
{
|
||||||
let body = response
|
let body_text = get_response_body_text(response).await?;
|
||||||
.json::<Value>()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
|
||||||
|
|
||||||
// dbg!(&body);
|
// dbg!(&body_text);
|
||||||
|
|
||||||
let search_response: SearchResponse<T> = serde_json::from_value(body)
|
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
|
||||||
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
||||||
|
|
||||||
Ok(search_response)
|
Ok(search_response)
|
||||||
@@ -80,6 +78,7 @@ where
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn parse_search_results_with_score<T>(
|
pub async fn parse_search_results_with_score<T>(
|
||||||
response: Response,
|
response: Response,
|
||||||
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
|
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
use crate::common::search::{QueryResponse, QuerySource};
|
use crate::common::error::SearchError;
|
||||||
use thiserror::Error;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
// use std::{future::Future, pin::Pin};
|
// use std::{future::Future, pin::Pin};
|
||||||
use crate::common::search::SearchQuery;
|
use crate::common::search::SearchQuery;
|
||||||
use serde::Serialize;
|
use crate::common::search::{QueryResponse, QuerySource};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SearchSource: Send + Sync {
|
pub trait SearchSource: Send + Sync {
|
||||||
@@ -13,32 +11,3 @@ pub trait SearchSource: Send + Sync {
|
|||||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error, Serialize)]
|
|
||||||
pub enum SearchError {
|
|
||||||
#[error("HTTP request failed: {0}")]
|
|
||||||
HttpError(String),
|
|
||||||
|
|
||||||
#[error("Invalid response format: {0}")]
|
|
||||||
ParseError(String),
|
|
||||||
|
|
||||||
#[error("Timeout occurred")]
|
|
||||||
Timeout,
|
|
||||||
|
|
||||||
#[error("Unknown error: {0}")]
|
|
||||||
Unknown(String),
|
|
||||||
|
|
||||||
#[error("InternalError error: {0}")]
|
|
||||||
InternalError(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<reqwest::Error> for SearchError {
|
|
||||||
fn from(err: reqwest::Error) -> Self {
|
|
||||||
if err.is_timeout() {
|
|
||||||
SearchError::Timeout
|
|
||||||
} else if err.is_decode() {
|
|
||||||
SearchError::ParseError(err.to_string())
|
|
||||||
} else {
|
|
||||||
SearchError::HttpError(err.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,14 +15,13 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
|||||||
use autostart::{change_autostart, enable_autostart};
|
use autostart::{change_autostart, enable_autostart};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use tauri::async_runtime::block_on;
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
use tauri::ActivationPolicy;
|
use tauri::ActivationPolicy;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window,
|
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
|
||||||
WindowEvent,
|
|
||||||
};
|
};
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tokio::runtime::Runtime as RT;
|
|
||||||
|
|
||||||
/// Tauri store name
|
/// Tauri store name
|
||||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||||
@@ -32,7 +31,7 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn change_window_height(handle: AppHandle, height: u32) {
|
async fn change_window_height(handle: AppHandle, height: u32) {
|
||||||
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||||
|
|
||||||
let mut size = window.outer_size().unwrap();
|
let mut size = window.outer_size().unwrap();
|
||||||
@@ -42,10 +41,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
|
|||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ThemeChangedPayload {
|
struct ThemeChangedPayload {
|
||||||
|
#[allow(dead_code)]
|
||||||
is_dark_mode: bool,
|
is_dark_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, serde::Serialize)]
|
#[derive(Clone, serde::Serialize)]
|
||||||
|
#[allow(dead_code)]
|
||||||
struct Payload {
|
struct Payload {
|
||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
cwd: String,
|
cwd: String,
|
||||||
@@ -53,7 +54,7 @@ struct Payload {
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let mut ctx = tauri::generate_context!();
|
let ctx = tauri::generate_context!();
|
||||||
// Initialize logger
|
// Initialize logger
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
@@ -81,7 +82,8 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_macos_permissions::init())
|
.plugin(tauri_plugin_macos_permissions::init())
|
||||||
.plugin(tauri_plugin_screenshots::init())
|
.plugin(tauri_plugin_screenshots::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_windows_version::init());
|
||||||
|
|
||||||
// Conditional compilation for macOS
|
// Conditional compilation for macOS
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -109,7 +111,8 @@ pub fn run() {
|
|||||||
server::servers::disable_server,
|
server::servers::disable_server,
|
||||||
server::auth::handle_sso_callback,
|
server::auth::handle_sso_callback,
|
||||||
server::profile::get_user_profiles,
|
server::profile::get_user_profiles,
|
||||||
server::datasource::get_datasources_by_server,
|
server::datasource::datasource_search,
|
||||||
|
server::datasource::mcp_server_search,
|
||||||
server::connector::get_connectors_by_server,
|
server::connector::get_connectors_by_server,
|
||||||
search::query_coco_fusion,
|
search::query_coco_fusion,
|
||||||
assistant::chat_history,
|
assistant::chat_history,
|
||||||
@@ -119,11 +122,22 @@ pub fn run() {
|
|||||||
assistant::open_session_chat,
|
assistant::open_session_chat,
|
||||||
assistant::close_session_chat,
|
assistant::close_session_chat,
|
||||||
assistant::cancel_session_chat,
|
assistant::cancel_session_chat,
|
||||||
|
assistant::delete_session_chat,
|
||||||
|
assistant::update_session_chat,
|
||||||
|
assistant::assistant_search,
|
||||||
// server::get_coco_server_datasources,
|
// server::get_coco_server_datasources,
|
||||||
// server::get_coco_server_connectors,
|
// server::get_coco_server_connectors,
|
||||||
server::websocket::connect_to_server,
|
server::websocket::connect_to_server,
|
||||||
server::websocket::disconnect,
|
server::websocket::disconnect,
|
||||||
get_app_search_source
|
get_app_search_source,
|
||||||
|
server::attachment::upload_attachment,
|
||||||
|
server::attachment::get_attachment,
|
||||||
|
server::attachment::delete_attachment,
|
||||||
|
server::transcription::transcription,
|
||||||
|
local::application::get_default_search_paths,
|
||||||
|
local::application::list_app_with_metadata_in,
|
||||||
|
util::open,
|
||||||
|
server::system_settings::get_system_settings
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let registry = SearchSourceRegistry::default();
|
let registry = SearchSourceRegistry::default();
|
||||||
@@ -131,20 +145,12 @@ pub fn run() {
|
|||||||
app.manage(registry); // Store registry in Tauri's app state
|
app.manage(registry); // Store registry in Tauri's app state
|
||||||
app.manage(server::websocket::WebSocketManager::default());
|
app.manage(server::websocket::WebSocketManager::default());
|
||||||
|
|
||||||
// Get app handle
|
block_on(async {
|
||||||
let app_handle = app.handle().clone();
|
init(app.handle()).await;
|
||||||
|
|
||||||
// Create a single Tokio runtime instance
|
|
||||||
let rt = RT::new().expect("Failed to create Tokio runtime");
|
|
||||||
|
|
||||||
// Use the runtime to spawn the async initialization tasks
|
|
||||||
let init_app_handle = app.handle().clone();
|
|
||||||
rt.spawn(async move {
|
|
||||||
init(&init_app_handle).await; // Pass a reference to `app_handle`
|
|
||||||
});
|
});
|
||||||
|
|
||||||
shortcut::enable_shortcut(&app);
|
shortcut::enable_shortcut(app);
|
||||||
// enable_tray(app);
|
|
||||||
enable_autostart(app);
|
enable_autostart(app);
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -221,7 +227,7 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
let coco_servers = server::servers::get_all_servers();
|
let coco_servers = server::servers::get_all_servers();
|
||||||
|
|
||||||
// Get the registry from Tauri's state
|
// Get the registry from Tauri's state
|
||||||
let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
||||||
|
|
||||||
for server in coco_servers {
|
for server in coco_servers {
|
||||||
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
|
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
|
||||||
@@ -232,46 +238,39 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
|||||||
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||||
let application_search =
|
let application_search =
|
||||||
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
|
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
|
||||||
|
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
|
||||||
|
|
||||||
// Register the application search source
|
// Register the application search source
|
||||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||||
registry.register_source(application_search).await;
|
registry.register_source(application_search).await;
|
||||||
|
registry.register_source(calculator_search).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn show_coco(app_handle: AppHandle) {
|
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||||
handle_open_coco(&app_handle);
|
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
|
||||||
}
|
let _ = app_handle.emit("show-coco", ());
|
||||||
|
|
||||||
#[tauri::command]
|
move_window_to_active_monitor(&window);
|
||||||
fn hide_coco(app: tauri::AppHandle) {
|
|
||||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
let _ = window.show();
|
||||||
match window.is_visible() {
|
let _ = window.unminimize();
|
||||||
Ok(true) => {
|
let _ = window.set_focus();
|
||||||
if let Err(err) = window.hide() {
|
|
||||||
eprintln!("Failed to hide the window: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(false) => {
|
|
||||||
println!("Window is already hidden.");
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
eprintln!("Failed to check window visibility: {}", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_open_coco(app: &AppHandle) {
|
#[tauri::command]
|
||||||
|
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||||
move_window_to_active_monitor(&window);
|
if let Err(err) = window.hide() {
|
||||||
|
eprintln!("Failed to hide the window: {}", err);
|
||||||
window.show().unwrap();
|
} else {
|
||||||
window.set_visible_on_all_workspaces(true).unwrap();
|
println!("Window successfully hidden.");
|
||||||
window.set_always_on_top(true).unwrap();
|
}
|
||||||
window.set_focus().unwrap();
|
} else {
|
||||||
|
eprintln!("Main window not found.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,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)]
|
#[allow(dead_code)]
|
||||||
fn open_settings(app: &tauri::AppHandle) {
|
fn open_settings(app: &tauri::AppHandle) {
|
||||||
use tauri::webview::WebviewBuilder;
|
use tauri::webview::WebviewBuilder;
|
||||||
println!("settings menu item was clicked");
|
println!("settings menu item was clicked");
|
||||||
let window = app.get_webview_window("settings");
|
let window = app.get_webview_window("settings");
|
||||||
if let Some(window) = window {
|
if let Some(window) = window {
|
||||||
window.show().unwrap();
|
let _ = window.show();
|
||||||
window.set_focus().unwrap();
|
let _ = window.unminimize();
|
||||||
|
let _ = window.set_focus();
|
||||||
} else {
|
} else {
|
||||||
let window = tauri::window::WindowBuilder::new(app, "settings")
|
let window = tauri::window::WindowBuilder::new(app, "settings")
|
||||||
.title("Settings Window")
|
.title("Settings Window")
|
||||||
|
|||||||
@@ -1,21 +1,196 @@
|
|||||||
use crate::common::document::{DataSourceReference, Document};
|
use crate::common::document::{DataSourceReference, Document};
|
||||||
|
use crate::common::error::SearchError;
|
||||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
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 crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||||
use applications::{AppInfo, AppInfoContext};
|
use applications::App;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use base64::encode;
|
|
||||||
use fuzzy_prefix_search::Trie;
|
use fuzzy_prefix_search::Trie;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::path::PathBuf;
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use tauri::{AppHandle, Runtime};
|
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 {
|
pub struct ApplicationSearchSource {
|
||||||
base_score: f64,
|
base_score: f64,
|
||||||
|
// app name -> app icon path
|
||||||
icons: HashMap<String, PathBuf>,
|
icons: HashMap<String, PathBuf>,
|
||||||
application_paths: Trie<String>,
|
application_paths: Trie<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApplicationSearchSource {
|
impl ApplicationSearchSource {
|
||||||
@@ -26,30 +201,20 @@ impl ApplicationSearchSource {
|
|||||||
let application_paths = Trie::new();
|
let application_paths = Trie::new();
|
||||||
let mut icons = HashMap::new();
|
let mut icons = HashMap::new();
|
||||||
|
|
||||||
let mut ctx = AppInfoContext::new(vec![]);
|
let default_search_path = get_default_search_paths();
|
||||||
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
|
let apps = list_app_in(default_search_path)?;
|
||||||
let apps = ctx.get_all_apps();
|
|
||||||
|
|
||||||
for app in &apps {
|
for app in &apps {
|
||||||
let path = if cfg!(target_os = "macos") {
|
let app_path = get_app_path(app);
|
||||||
app.app_desktop_path.clone()
|
let app_name = get_app_name(app).await;
|
||||||
} else {
|
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
|
||||||
app.app_path_exe
|
|
||||||
.clone()
|
|
||||||
.unwrap_or(PathBuf::from("Path not found"))
|
|
||||||
};
|
|
||||||
let search_word = name(path.clone()).await;
|
|
||||||
let icon = icon(app_handle.clone(), path.clone(), Some(256))
|
|
||||||
.await
|
|
||||||
.map_err(|err| err.to_string())?;
|
|
||||||
let path_string = path.to_string_lossy().into_owned();
|
|
||||||
|
|
||||||
if search_word.is_empty() || search_word.eq("coco-ai") {
|
if app_name.is_empty() || app_name.eq("Coco-AI") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
application_paths.insert(&search_word, path_string.clone());
|
application_paths.insert(&app_name, app_path);
|
||||||
icons.insert(path_string, icon);
|
icons.insert(app_name, app_icon_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(ApplicationSearchSource {
|
Ok(ApplicationSearchSource {
|
||||||
@@ -69,7 +234,7 @@ impl SearchSource for ApplicationSearchSource {
|
|||||||
.unwrap_or("My Computer".into())
|
.unwrap_or("My Computer".into())
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into(),
|
.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 total_hits = 0;
|
||||||
let mut hits = Vec::new();
|
let mut hits = Vec::new();
|
||||||
|
|
||||||
|
let query_string_len = query_string.len();
|
||||||
let mut results = self
|
let mut results = self
|
||||||
.application_paths
|
.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
|
// Check for NaN or extreme score values and handle them properly
|
||||||
results.sort_by(|a, b| {
|
results.sort_by(|a, b| {
|
||||||
@@ -110,30 +276,28 @@ impl SearchSource for ApplicationSearchSource {
|
|||||||
|
|
||||||
if !results.is_empty() {
|
if !results.is_empty() {
|
||||||
for result in results {
|
for result in results {
|
||||||
let file_name_str = result.word;
|
let app_name = result.word;
|
||||||
let file_path_str = result.data.get(0).unwrap().to_string();
|
let app_path = result.data.first().unwrap().clone();
|
||||||
let file_path = PathBuf::from(file_path_str.clone());
|
let app_path_string = app_path.to_string_lossy().into_owned();
|
||||||
let cleaned_file_name = name(file_path).await;
|
|
||||||
total_hits += 1;
|
total_hits += 1;
|
||||||
|
|
||||||
let mut doc = Document::new(
|
let mut doc = Document::new(
|
||||||
Some(DataSourceReference {
|
Some(DataSourceReference {
|
||||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||||
name: Some("Applications".into()),
|
name: Some(DATA_SOURCE_ID.into()),
|
||||||
id: Some(file_name_str.clone()),
|
id: Some(DATA_SOURCE_ID.into()),
|
||||||
}),
|
icon: None,
|
||||||
file_path_str.clone(),
|
}),
|
||||||
"Application".to_string(),
|
app_path_string.clone(),
|
||||||
cleaned_file_name,
|
"Application".to_string(),
|
||||||
file_path_str.clone(),
|
app_name.clone(),
|
||||||
);
|
app_path_string.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
// Attach icon if available
|
// Attach icon if available
|
||||||
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
|
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
|
||||||
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
|
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
|
||||||
// dbg!(&doc.icon);
|
|
||||||
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
|
|
||||||
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hits.push((doc, self.base_score + result.score as f64));
|
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 application;
|
||||||
|
pub mod calculator;
|
||||||
pub mod file_system;
|
pub mod file_system;
|
||||||
|
|
||||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
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::register::SearchSourceRegistry;
|
||||||
use crate::common::search::{
|
use crate::common::search::{
|
||||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||||
};
|
};
|
||||||
use crate::common::traits::SearchError;
|
|
||||||
use futures::stream::FuturesUnordered;
|
use futures::stream::FuturesUnordered;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -16,7 +16,10 @@ pub async fn query_coco_fusion<R: Runtime>(
|
|||||||
from: u64,
|
from: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
query_strings: HashMap<String, String>,
|
query_strings: HashMap<String, String>,
|
||||||
|
query_timeout: u64,
|
||||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||||
|
let query_source_to_search = query_strings.get("querysource");
|
||||||
|
|
||||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||||
|
|
||||||
let sources_future = search_sources.get_sources();
|
let sources_future = search_sources.get_sources();
|
||||||
@@ -26,11 +29,19 @@ pub async fn query_coco_fusion<R: Runtime>(
|
|||||||
let sources_list = sources_future.await;
|
let sources_list = sources_future.await;
|
||||||
|
|
||||||
// Time limit for each query
|
// Time limit for each query
|
||||||
let timeout_duration = Duration::from_millis(500); //TODO, settings
|
let timeout_duration = Duration::from_millis(query_timeout);
|
||||||
|
|
||||||
// Push all queries into futures
|
// Push all queries into futures
|
||||||
for query_source in sources_list {
|
for query_source in sources_list {
|
||||||
let query_source_type = query_source.get_type().clone();
|
let query_source_type = query_source.get_type().clone();
|
||||||
|
|
||||||
|
if let Some(query_source_to_search) = query_source_to_search {
|
||||||
|
// We should not search this data source
|
||||||
|
if &query_source_type.id != query_source_to_search {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sources.insert(query_source_type.id.clone(), query_source_type);
|
sources.insert(query_source_type.id.clone(), query_source_type);
|
||||||
|
|
||||||
let query = SearchQuery::new(from, size, query_strings.clone());
|
let query = SearchQuery::new(from, size, query_strings.clone());
|
||||||
|
|||||||
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,11 +1,12 @@
|
|||||||
use crate::common::auth::RequestAccessTokenResponse;
|
|
||||||
use crate::common::server::ServerAccessToken;
|
use crate::common::server::ServerAccessToken;
|
||||||
use crate::server::http_client::HttpClient;
|
|
||||||
use crate::server::profile::get_user_profiles;
|
use crate::server::profile::get_user_profiles;
|
||||||
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server, try_register_server_to_search_source};
|
use crate::server::servers::{
|
||||||
use reqwest::StatusCode;
|
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||||
use std::collections::HashMap;
|
try_register_server_to_search_source,
|
||||||
|
};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn request_access_token_url(request_id: &str) -> String {
|
fn request_access_token_url(request_id: &str) -> String {
|
||||||
// Remove the endpoint part and keep just the path for the request
|
// Remove the endpoint part and keep just the path for the request
|
||||||
format!("/auth/request_access_token?request_id={}", request_id)
|
format!("/auth/request_access_token?request_id={}", request_id)
|
||||||
@@ -21,71 +22,30 @@ pub async fn handle_sso_callback<R: Runtime>(
|
|||||||
// Retrieve the server details using the server ID
|
// Retrieve the server details using the server ID
|
||||||
let server = get_server_by_id(&server_id);
|
let server = get_server_by_id(&server_id);
|
||||||
|
|
||||||
|
let expire_in = 3600; // TODO, need to update to actual expire_in value
|
||||||
if let Some(mut server) = server {
|
if let Some(mut server) = server {
|
||||||
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
|
// Save the access token for the server
|
||||||
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
|
let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
|
||||||
let path = request_access_token_url(&request_id);
|
// dbg!(&server_id, &request_id, &code, &token);
|
||||||
|
save_access_token(server_id.clone(), access_token);
|
||||||
|
persist_servers_token(&app_handle)?;
|
||||||
|
|
||||||
// Send the request for the access token using the util::http::HttpClient::get method
|
// Register the server to the search source
|
||||||
let mut header = HashMap::new();
|
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||||
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 {
|
// Update the server's profile using the util::http::HttpClient::get method
|
||||||
// Check if the response has a valid content length
|
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
|
||||||
if let Some(content_length) = response.content_length() {
|
dbg!(&profile);
|
||||||
if content_length > 0 {
|
|
||||||
// Deserialize the response body to get the access token
|
|
||||||
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
|
|
||||||
|
|
||||||
match token_result {
|
match profile {
|
||||||
Ok(token) => {
|
Ok(p) => {
|
||||||
// Save the access token for the server
|
server.profile = Some(p);
|
||||||
let access_token = ServerAccessToken::new(
|
server.available = true;
|
||||||
server_id.clone(),
|
save_server(&server);
|
||||||
token.access_token.clone(),
|
persist_servers(&app_handle).await?;
|
||||||
token.expire_in,
|
Ok(())
|
||||||
);
|
|
||||||
// dbg!(&server_id, &request_id, &code, &token);
|
|
||||||
save_access_token(server_id.clone(), access_token);
|
|
||||||
persist_servers_token(&app_handle)?;
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
dbg!(&profile);
|
|
||||||
|
|
||||||
match profile {
|
|
||||||
Ok(p) => {
|
|
||||||
server.profile = Some(p);
|
|
||||||
server.available = true;
|
|
||||||
save_server(&server);
|
|
||||||
persist_servers(&app_handle).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("Failed to get user profile: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("Failed to deserialize the token response: {}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Received empty response body.".to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Could not determine the content length.".to_string())
|
|
||||||
}
|
}
|
||||||
} else {
|
Err(e) => Err(format!("Failed to get user profile: {}", e)),
|
||||||
Err(format!(
|
|
||||||
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
|
|
||||||
response.status(),
|
|
||||||
path,
|
|
||||||
code,
|
|
||||||
response
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
@@ -93,4 +53,4 @@ pub async fn handle_sso_callback<R: Runtime>(
|
|||||||
server_id, request_id, code
|
server_id, request_id, code
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn get_connectors_from_cache_or_remote(
|
pub async fn get_connectors_from_cache_or_remote(
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
) -> Result<Vec<Connector>, String> {
|
) -> Result<Vec<Connector>, String> {
|
||||||
@@ -96,7 +97,7 @@ pub async fn get_connectors_from_cache_or_remote(
|
|||||||
|
|
||||||
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
|
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
|
||||||
// Use the generic GET method from HttpClient
|
// Use the generic GET method from HttpClient
|
||||||
let resp = HttpClient::get(&id, "/connector/_search",None)
|
let resp = HttpClient::get(&id, "/connector/_search", None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
// dbg!("Error fetching connector for id {}: {}", &id, &e);
|
// dbg!("Error fetching connector for id {}: {}", &id, &e);
|
||||||
@@ -104,9 +105,9 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Parse the search results directly from the response body
|
// Parse the search results directly from the response body
|
||||||
let datasource: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
|
let datasource: Vec<Connector> = parse_search_results(resp)
|
||||||
e.to_string()
|
.await
|
||||||
})?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Save the connectors to the cache
|
// Save the connectors to the cache
|
||||||
save_connectors_to_cache(&id, datasource.clone());
|
save_connectors_to_cache(&id, datasource.clone());
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, RwLock};
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Debug)]
|
||||||
|
pub struct GetDatasourcesByServerOptions {
|
||||||
|
pub from: Option<u32>,
|
||||||
|
pub size: Option<u32>,
|
||||||
|
pub query: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
||||||
Arc::new(RwLock::new(HashMap::new()));
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
@@ -22,14 +29,15 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
|||||||
cache.insert(server_id.to_string(), datasources_map);
|
cache.insert(server_id.to_string(), datasources_map);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||||
// dbg!("cache: {:?}", &cache);
|
// dbg!("cache: {:?}", &cache);
|
||||||
let server_cache = cache.get(server_id)?; // Get the server's cache
|
let server_cache = cache.get(server_id)?; // Get the server's cache
|
||||||
Some(server_cache.clone())
|
Some(server_cache.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||||
// dbg!("Attempting to refresh all datasources");
|
// dbg!("Attempting to refresh all datasources");
|
||||||
|
|
||||||
let servers = get_all_servers();
|
let servers = get_all_servers();
|
||||||
@@ -40,22 +48,21 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
|||||||
// dbg!("fetch datasources for server: {}", &server.id);
|
// dbg!("fetch datasources for server: {}", &server.id);
|
||||||
|
|
||||||
// Attempt to get datasources by server, and continue even if it fails
|
// Attempt to get datasources by server, and continue even if it fails
|
||||||
let connectors =
|
let connectors = match datasource_search(server.id.as_str(), None).await {
|
||||||
match get_datasources_by_server(server.id.as_str()).await {
|
Ok(connectors) => {
|
||||||
Ok(connectors) => {
|
// Process connectors only after fetching them
|
||||||
// Process connectors only after fetching them
|
let connectors_map: HashMap<String, DataSource> = connectors
|
||||||
let connectors_map: HashMap<String, DataSource> = connectors
|
.into_iter()
|
||||||
.into_iter()
|
.map(|connector| (connector.id.clone(), connector))
|
||||||
.map(|connector| (connector.id.clone(), connector))
|
.collect();
|
||||||
.collect();
|
// dbg!("connectors_map: {:?}", &connectors_map);
|
||||||
// dbg!("connectors_map: {:?}", &connectors_map);
|
connectors_map
|
||||||
connectors_map
|
}
|
||||||
}
|
Err(_e) => {
|
||||||
Err(_e) => {
|
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
|
||||||
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
|
HashMap::new()
|
||||||
HashMap::new()
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
let mut new_map = HashMap::new();
|
let mut new_map = HashMap::new();
|
||||||
for (id, datasource) in connectors.iter() {
|
for (id, datasource) in connectors.iter() {
|
||||||
@@ -79,23 +86,52 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
|||||||
cache.extend(server_map);
|
cache.extend(server_map);
|
||||||
cache.len()
|
cache.len()
|
||||||
};
|
};
|
||||||
// dbg!("datasource_map size: {:?}", cache_size);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_datasources_by_server(
|
pub async fn datasource_search(
|
||||||
id: &str,
|
id: &str,
|
||||||
|
options: Option<GetDatasourcesByServerOptions>,
|
||||||
) -> Result<Vec<DataSource>, String> {
|
) -> Result<Vec<DataSource>, String> {
|
||||||
|
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||||
|
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||||
|
let query = options
|
||||||
|
.and_then(|opt| opt.query)
|
||||||
|
.unwrap_or(String::default());
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"from": from,
|
||||||
|
"size": size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !query.is_empty() {
|
||||||
|
body["query"] = serde_json::json!({
|
||||||
|
"bool": {
|
||||||
|
"must": [{
|
||||||
|
"query_string": {
|
||||||
|
"fields": ["combined_fulltext"],
|
||||||
|
"query": query,
|
||||||
|
"fuzziness": "AUTO",
|
||||||
|
"fuzzy_prefix_length": 2,
|
||||||
|
"fuzzy_max_expansions": 10,
|
||||||
|
"fuzzy_transpositions": true,
|
||||||
|
"allow_leading_wildcard": false
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Perform the async HTTP request outside the cache lock
|
// Perform the async HTTP request outside the cache lock
|
||||||
let resp = HttpClient::get(id, "/datasource/_search", None)
|
let resp = HttpClient::post(
|
||||||
.await
|
id,
|
||||||
.map_err(|e| {
|
"/datasource/_search",
|
||||||
// dbg!("Error fetching datasource: {}", &e);
|
None,
|
||||||
format!("Error fetching datasource: {}", e)
|
Some(reqwest::Body::from(body.to_string())),
|
||||||
})?;
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||||
|
|
||||||
// Parse the search results from the response
|
// Parse the search results from the response
|
||||||
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
let datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||||
@@ -108,3 +144,59 @@ pub async fn get_datasources_by_server(
|
|||||||
|
|
||||||
Ok(datasources)
|
Ok(datasources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn mcp_server_search(
|
||||||
|
id: &str,
|
||||||
|
options: Option<GetDatasourcesByServerOptions>,
|
||||||
|
) -> Result<Vec<DataSource>, String> {
|
||||||
|
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||||
|
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||||
|
let query = options
|
||||||
|
.and_then(|opt| opt.query)
|
||||||
|
.unwrap_or(String::default());
|
||||||
|
|
||||||
|
let mut body = serde_json::json!({
|
||||||
|
"from": from,
|
||||||
|
"size": size,
|
||||||
|
});
|
||||||
|
|
||||||
|
if !query.is_empty() {
|
||||||
|
body["query"] = serde_json::json!({
|
||||||
|
"bool": {
|
||||||
|
"must": [{
|
||||||
|
"query_string": {
|
||||||
|
"fields": ["combined_fulltext"],
|
||||||
|
"query": query,
|
||||||
|
"fuzziness": "AUTO",
|
||||||
|
"fuzzy_prefix_length": 2,
|
||||||
|
"fuzzy_max_expansions": 10,
|
||||||
|
"fuzzy_transpositions": true,
|
||||||
|
"allow_leading_wildcard": false
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the async HTTP request outside the cache lock
|
||||||
|
let resp = HttpClient::post(
|
||||||
|
id,
|
||||||
|
"/mcp_server/_search",
|
||||||
|
None,
|
||||||
|
Some(reqwest::Body::from(body.to_string())),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Error fetching datasource: {}", e))?;
|
||||||
|
|
||||||
|
// Parse the search results from the response
|
||||||
|
let mcp_server: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||||
|
dbg!("Error parsing search results: {}", &e);
|
||||||
|
e.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Save the updated mcp_server to cache
|
||||||
|
// save_datasource_to_cache(&id, mcp_server.clone());
|
||||||
|
|
||||||
|
Ok(mcp_server)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||||
use http::HeaderName;
|
use http::{HeaderName, HeaderValue};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use reqwest::{Client, Method, RequestBuilder};
|
use reqwest::{Client, Method, RequestBuilder};
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::ipc::RuntimeCapability;
|
use tauri_plugin_store::JsonValue;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
||||||
@@ -32,14 +31,17 @@ impl HttpClient {
|
|||||||
pub async fn send_raw_request(
|
pub async fn send_raw_request(
|
||||||
method: Method,
|
method: Method,
|
||||||
url: &str,
|
url: &str,
|
||||||
query_params: Option<HashMap<String, Value>>,
|
query_params: Option<HashMap<String, JsonValue>>,
|
||||||
headers: Option<HashMap<String, String>>,
|
headers: Option<HashMap<String, String>>,
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
let mut request_builder = Self::get_request_builder(method, url, headers, query_params, body).await;
|
let request_builder =
|
||||||
|
Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||||
|
|
||||||
let response = request_builder.send().await
|
let response = request_builder.send().await.map_err(|e| {
|
||||||
.map_err(|e| format!("Failed to send request: {}", e))?;
|
dbg!("Failed to send request: {}", &e);
|
||||||
|
format!("Failed to send request: {}", e)
|
||||||
|
})?;
|
||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ impl HttpClient {
|
|||||||
method: Method,
|
method: Method,
|
||||||
url: &str,
|
url: &str,
|
||||||
headers: Option<HashMap<String, String>>,
|
headers: Option<HashMap<String, String>>,
|
||||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> RequestBuilder {
|
) -> RequestBuilder {
|
||||||
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
||||||
@@ -55,21 +57,51 @@ impl HttpClient {
|
|||||||
// Build the request
|
// Build the request
|
||||||
let mut request_builder = client.request(method.clone(), url);
|
let mut request_builder = client.request(method.clone(), url);
|
||||||
|
|
||||||
|
|
||||||
if let Some(h) = headers {
|
if let Some(h) = headers {
|
||||||
let mut req_headers = reqwest::header::HeaderMap::new();
|
let mut req_headers = reqwest::header::HeaderMap::new();
|
||||||
for (key, value) in h.into_iter() {
|
for (key, value) in h.into_iter() {
|
||||||
let _ = req_headers.insert(
|
match (
|
||||||
HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
HeaderName::from_bytes(key.as_bytes()),
|
||||||
reqwest::header::HeaderValue::from_str(&value).unwrap(),
|
HeaderValue::from_str(value.trim()),
|
||||||
);
|
) {
|
||||||
|
(Ok(name), Ok(val)) => {
|
||||||
|
req_headers.insert(name, val);
|
||||||
|
}
|
||||||
|
(Err(e), _) => {
|
||||||
|
eprintln!("Invalid header name: {:?}, error: {}", key, e);
|
||||||
|
}
|
||||||
|
(_, Err(e)) => {
|
||||||
|
eprintln!(
|
||||||
|
"Invalid header value for {}: {:?}, error: {}",
|
||||||
|
key, value, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
request_builder = request_builder.headers(req_headers);
|
request_builder = request_builder.headers(req_headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(query) = query_params {
|
if let Some(query) = query_params {
|
||||||
|
// Convert only supported value types into strings
|
||||||
|
let query: HashMap<String, String> = query
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
match v {
|
||||||
|
JsonValue::String(s) => Some((k, s)),
|
||||||
|
JsonValue::Number(n) => Some((k, n.to_string())),
|
||||||
|
JsonValue::Bool(b) => Some((k, b.to_string())),
|
||||||
|
_ => {
|
||||||
|
dbg!(
|
||||||
|
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
} // skip arrays, objects, nulls
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
request_builder = request_builder.query(&query);
|
request_builder = request_builder.query(&query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add body if present
|
// Add body if present
|
||||||
if let Some(b) = body {
|
if let Some(b) = body {
|
||||||
request_builder = request_builder.body(b);
|
request_builder = request_builder.body(b);
|
||||||
@@ -83,7 +115,7 @@ impl HttpClient {
|
|||||||
method: Method,
|
method: Method,
|
||||||
path: &str,
|
path: &str,
|
||||||
custom_headers: Option<HashMap<String, String>>,
|
custom_headers: Option<HashMap<String, String>>,
|
||||||
query_params: Option<HashMap<String, Value>>,
|
query_params: Option<HashMap<String, JsonValue>>,
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
// Fetch the server using the server_id
|
// Fetch the server using the server_id
|
||||||
@@ -93,23 +125,21 @@ impl HttpClient {
|
|||||||
let url = HttpClient::join_url(&s.endpoint, path);
|
let url = HttpClient::join_url(&s.endpoint, path);
|
||||||
|
|
||||||
// Retrieve the token for the server (token is optional)
|
// Retrieve the token for the server (token is optional)
|
||||||
let token = get_server_token(server_id).map(|t| t.access_token.clone());
|
let token = get_server_token(server_id)
|
||||||
|
.await?
|
||||||
|
.map(|t| t.access_token.clone());
|
||||||
|
|
||||||
let mut headers = if let Some(custom_headers) = custom_headers {
|
let mut headers = if let Some(custom_headers) = custom_headers {
|
||||||
custom_headers
|
custom_headers
|
||||||
} else {
|
} else {
|
||||||
let mut headers = HashMap::new();
|
let headers = HashMap::new();
|
||||||
headers
|
headers
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(t) = token {
|
if let Some(t) = token {
|
||||||
headers.insert(
|
headers.insert("X-API-TOKEN".to_string(), t);
|
||||||
"X-API-TOKEN".to_string(),
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// dbg!(&server_id);
|
// dbg!(&server_id);
|
||||||
// dbg!(&url);
|
// dbg!(&url);
|
||||||
// dbg!(&headers);
|
// dbg!(&headers);
|
||||||
@@ -121,7 +151,10 @@ impl HttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience method for GET requests (as it's the most common)
|
// Convenience method for GET requests (as it's the most common)
|
||||||
pub async fn get(server_id: &str, path: &str, query_params: Option<HashMap<String, Value>>, // Add query parameters
|
pub async fn get(
|
||||||
|
server_id: &str,
|
||||||
|
path: &str,
|
||||||
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
|
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
|
||||||
}
|
}
|
||||||
@@ -130,7 +163,7 @@ impl HttpClient {
|
|||||||
pub async fn post(
|
pub async fn post(
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
|
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
|
||||||
@@ -140,27 +173,56 @@ impl HttpClient {
|
|||||||
server_id: &str,
|
server_id: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
custom_headers: Option<HashMap<String, String>>,
|
custom_headers: Option<HashMap<String, String>>,
|
||||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
HttpClient::send_request(server_id, Method::POST, path, custom_headers, query_params, body).await
|
HttpClient::send_request(
|
||||||
|
server_id,
|
||||||
|
Method::POST,
|
||||||
|
path,
|
||||||
|
custom_headers,
|
||||||
|
query_params,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience method for PUT requests
|
// Convenience method for PUT requests
|
||||||
|
#[allow(dead_code)]
|
||||||
pub async fn put(
|
pub async fn put(
|
||||||
server_id: &str,
|
server_id: &str,
|
||||||
path: &str,
|
path: &str,
|
||||||
custom_headers: Option<HashMap<String, String>>,
|
custom_headers: Option<HashMap<String, String>>,
|
||||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
body: Option<reqwest::Body>,
|
body: Option<reqwest::Body>,
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
HttpClient::send_request(server_id, Method::PUT, path, custom_headers, query_params, body).await
|
HttpClient::send_request(
|
||||||
|
server_id,
|
||||||
|
Method::PUT,
|
||||||
|
path,
|
||||||
|
custom_headers,
|
||||||
|
query_params,
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience method for DELETE requests
|
// Convenience method for DELETE requests
|
||||||
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>,
|
#[allow(dead_code)]
|
||||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
pub async fn delete(
|
||||||
|
server_id: &str,
|
||||||
|
path: &str,
|
||||||
|
custom_headers: Option<HashMap<String, String>>,
|
||||||
|
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||||
) -> Result<reqwest::Response, String> {
|
) -> Result<reqwest::Response, String> {
|
||||||
HttpClient::send_request(server_id, Method::DELETE, path, custom_headers, query_params, None).await
|
HttpClient::send_request(
|
||||||
|
server_id,
|
||||||
|
Method::DELETE,
|
||||||
|
path,
|
||||||
|
custom_headers,
|
||||||
|
query_params,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
//! This file contains Rust APIs related to Coco Server management.
|
//! This file contains Rust APIs related to Coco Server management.
|
||||||
|
|
||||||
|
pub mod attachment;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod servers;
|
|
||||||
pub mod connector;
|
pub mod connector;
|
||||||
pub mod datasource;
|
pub mod datasource;
|
||||||
pub mod http_client;
|
pub mod http_client;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod servers;
|
||||||
|
pub mod system_settings;
|
||||||
|
pub mod transcription;
|
||||||
pub mod websocket;
|
pub mod websocket;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::common::http::get_response_body_text;
|
||||||
use crate::common::profile::UserProfile;
|
use crate::common::profile::UserProfile;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use tauri::{AppHandle, Runtime};
|
use tauri::{AppHandle, Runtime};
|
||||||
@@ -12,14 +13,16 @@ pub async fn get_user_profiles<R: Runtime>(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Error fetching profile: {}", e))?;
|
.map_err(|e| format!("Error fetching profile: {}", e))?;
|
||||||
|
|
||||||
if let Some(content_length) = response.content_length() {
|
// Use get_response_body_text to extract the body content
|
||||||
if content_length > 0 {
|
let response_body = get_response_body_text(response)
|
||||||
let profile: UserProfile = response
|
.await
|
||||||
.json()
|
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
// Check if the response body is not empty before deserializing
|
||||||
return Ok(profile);
|
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())
|
Err("Profile not found or empty response".to_string())
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::common::document::Document;
|
use crate::common::document::Document;
|
||||||
use crate::common::search::{
|
use crate::common::error::SearchError;
|
||||||
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
use crate::common::http::get_response_body_text;
|
||||||
};
|
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
||||||
use crate::common::server::Server;
|
use crate::common::server::Server;
|
||||||
use crate::common::traits::{SearchError, SearchSource};
|
use crate::common::traits::SearchSource;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::server::servers::get_server_token;
|
use crate::server::servers::get_server_token;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -12,6 +12,8 @@ use ordered_float::OrderedFloat;
|
|||||||
use reqwest::{Client, Method, RequestBuilder};
|
use reqwest::{Client, Method, RequestBuilder};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
// use std::hash::Hash;
|
// use std::hash::Hash;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) struct DocumentsSizedCollector {
|
pub(crate) struct DocumentsSizedCollector {
|
||||||
size: u64,
|
size: u64,
|
||||||
/// Documents and scores
|
/// Documents and scores
|
||||||
@@ -20,6 +22,7 @@ pub(crate) struct DocumentsSizedCollector {
|
|||||||
docs: Vec<(String, Document, OrderedFloat<f64>)>,
|
docs: Vec<(String, Document, OrderedFloat<f64>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl DocumentsSizedCollector {
|
impl DocumentsSizedCollector {
|
||||||
pub(crate) fn new(size: u64) -> Self {
|
pub(crate) fn new(size: u64) -> Self {
|
||||||
// there will be size + 1 documents in docs at max
|
// there will be size + 1 documents in docs at max
|
||||||
@@ -43,7 +46,7 @@ impl DocumentsSizedCollector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn documents(self) -> impl ExactSizeIterator<Item = Document> {
|
fn documents(self) -> impl ExactSizeIterator<Item=Document> {
|
||||||
self.docs.into_iter().map(|(_, doc, _)| doc)
|
self.docs.into_iter().map(|(_, doc, _)| doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,28 +82,37 @@ impl CocoSearchSource {
|
|||||||
CocoSearchSource { server, client }
|
CocoSearchSource { server, client }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_request_from_query(&self, query: &SearchQuery) -> RequestBuilder {
|
async fn build_request_from_query(
|
||||||
|
&self,
|
||||||
|
query: &SearchQuery,
|
||||||
|
) -> Result<RequestBuilder, String> {
|
||||||
self.build_request(query.from, query.size, &query.query_strings)
|
self.build_request(query.from, query.size, &query.query_strings)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_request(
|
async fn build_request(
|
||||||
&self,
|
&self,
|
||||||
from: u64,
|
from: u64,
|
||||||
size: u64,
|
size: u64,
|
||||||
query_strings: &HashMap<String, String>,
|
query_strings: &HashMap<String, String>,
|
||||||
) -> RequestBuilder {
|
) -> Result<RequestBuilder, String> {
|
||||||
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
|
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
|
||||||
let mut request_builder = self.client.request(Method::GET, url);
|
let mut request_builder = self.client.request(Method::GET, url);
|
||||||
|
|
||||||
if !self.server.public {
|
if !self.server.public {
|
||||||
if let Some(token) = get_server_token(&self.server.id).map(|t| t.access_token) {
|
if let Some(token) = get_server_token(&self.server.id)
|
||||||
|
.await?
|
||||||
|
.map(|t| t.access_token)
|
||||||
|
{
|
||||||
request_builder = request_builder.header("X-API-TOKEN", token);
|
request_builder = request_builder.header("X-API-TOKEN", token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request_builder
|
let result = request_builder
|
||||||
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
|
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
|
||||||
.query(query_strings)
|
.query(query_strings);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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> {
|
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||||
let _server_id = self.server.id.clone();
|
// Build the request from the provided query
|
||||||
let _server_name = self.server.name.clone();
|
let request_builder = self
|
||||||
let request_builder = self.build_request_from_query(&query);
|
.build_request_from_query(&query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| SearchError::InternalError(e.to_string()))?;
|
||||||
|
|
||||||
// Send the HTTP request asynchronously
|
// Send the HTTP request and handle errors
|
||||||
let response = request_builder.send().await;
|
let response = request_builder
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| SearchError::HttpError(format!("Failed to send search request: {}", e)))?;
|
||||||
|
|
||||||
match response {
|
// Use the helper function to parse the response body
|
||||||
Ok(response) => {
|
let response_body = get_response_body_text(response)
|
||||||
let status_code = response.status().as_u16();
|
.await
|
||||||
|
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
|
||||||
|
|
||||||
if status_code >= 200 && status_code < 400 {
|
// Parse the search response from the body text
|
||||||
// Parse the response only if the status code is successful
|
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
|
||||||
match parse_search_response(response).await {
|
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
|
||||||
Ok(response) => {
|
|
||||||
let total_hits = response.hits.total.value as usize;
|
|
||||||
let hits: Vec<(Document, f64)> = response
|
|
||||||
.hits
|
|
||||||
.hits
|
|
||||||
.into_iter()
|
|
||||||
.map(|hit| {
|
|
||||||
// Handling Option<f64> in hit._score by defaulting to 0.0 if None
|
|
||||||
(hit._source, hit._score.unwrap_or(0.0)) // Use 0.0 if _score is None
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Return the QueryResponse with hits and total hits
|
// Process the parsed response
|
||||||
Ok(QueryResponse {
|
let total_hits = parsed.hits.total.value as usize;
|
||||||
source: self.get_type(),
|
let hits: Vec<(Document, f64)> = parsed
|
||||||
hits,
|
.hits
|
||||||
total_hits,
|
.hits
|
||||||
})
|
.into_iter()
|
||||||
}
|
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
||||||
Err(err) => {
|
.collect();
|
||||||
// Parse error when response parsing fails
|
|
||||||
Err(SearchError::ParseError(err.to_string()))
|
// Return the final result
|
||||||
}
|
Ok(QueryResponse {
|
||||||
}
|
source: self.get_type(),
|
||||||
} else {
|
hits,
|
||||||
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
|
total_hits,
|
||||||
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::register::SearchSourceRegistry;
|
||||||
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
|
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
|
||||||
use crate::server::connector::fetch_connectors_by_server;
|
use crate::server::connector::fetch_connectors_by_server;
|
||||||
use crate::server::datasource::get_datasources_by_server;
|
use crate::server::datasource::datasource_search;
|
||||||
use crate::server::http_client::HttpClient;
|
use crate::server::http_client::HttpClient;
|
||||||
use crate::server::search::CocoSearchSource;
|
use crate::server::search::CocoSearchSource;
|
||||||
use crate::COCO_TAURI_STORE;
|
use crate::COCO_TAURI_STORE;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use reqwest::{Client, Method, StatusCode};
|
use reqwest::{Client, Method};
|
||||||
use serde_json::from_value;
|
use serde_json::from_value;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -24,6 +25,7 @@ lazy_static! {
|
|||||||
Arc::new(RwLock::new(HashMap::new()));
|
Arc::new(RwLock::new(HashMap::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
fn check_server_exists(id: &str) -> bool {
|
fn check_server_exists(id: &str) -> bool {
|
||||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
||||||
cache.contains_key(id)
|
cache.contains_key(id)
|
||||||
@@ -35,9 +37,10 @@ pub fn get_server_by_id(id: &str) -> Option<Server> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
|
pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> {
|
||||||
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
|
let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
|
||||||
cache.get(id).cloned()
|
|
||||||
|
Ok(cache.get(id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
|
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
|
||||||
@@ -267,6 +270,7 @@ pub async fn list_coco_servers<R: Runtime>(
|
|||||||
Ok(servers)
|
Ok(servers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
|
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
|
||||||
let cache = SERVER_CACHE.read().unwrap();
|
let cache = SERVER_CACHE.read().unwrap();
|
||||||
cache.clone()
|
cache.clone()
|
||||||
@@ -295,61 +299,57 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
|||||||
id: String,
|
id: String,
|
||||||
) -> Result<Server, String> {
|
) -> Result<Server, String> {
|
||||||
// Retrieve the server from the cache
|
// Retrieve the server from the cache
|
||||||
let server = {
|
let cached_server = {
|
||||||
let cache = SERVER_CACHE.read().unwrap();
|
let cache = SERVER_CACHE.read().unwrap();
|
||||||
cache.get(&id).cloned()
|
cache.get(&id).cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(server) = server {
|
let server = match cached_server {
|
||||||
let is_enabled = server.enabled;
|
Some(server) => server,
|
||||||
let is_builtin = server.builtin;
|
None => return Err("Server not found.".into()),
|
||||||
let profile = server.profile;
|
};
|
||||||
|
|
||||||
// Use the HttpClient to send the request
|
// Preserve important local state
|
||||||
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint
|
let is_enabled = server.enabled;
|
||||||
.await
|
let is_builtin = server.builtin;
|
||||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
let profile = server.profile;
|
||||||
|
|
||||||
if response.status() == StatusCode::OK {
|
// Send request to fetch updated server info
|
||||||
if let Some(content_length) = response.content_length() {
|
let response = HttpClient::get(&id, "/provider/_info", None)
|
||||||
if content_length > 0 {
|
.await
|
||||||
let new_coco_server: Result<Server, _> = response.json().await;
|
.map_err(|e| format!("Failed to contact the server: {}", e))?;
|
||||||
match new_coco_server {
|
|
||||||
Ok(mut server) => {
|
|
||||||
server.id = id.clone();
|
|
||||||
server.builtin = is_builtin;
|
|
||||||
server.enabled = is_enabled;
|
|
||||||
server.available = true;
|
|
||||||
server.profile = profile;
|
|
||||||
trim_endpoint_last_forward_slash(&mut server);
|
|
||||||
save_server(&server);
|
|
||||||
persist_servers(&app_handle)
|
|
||||||
.await
|
|
||||||
.expect("Failed to persist coco servers.");
|
|
||||||
|
|
||||||
//refresh connectors and datasources
|
if !response.status().is_success() {
|
||||||
let _ = fetch_connectors_by_server(&id).await;
|
mark_server_as_offline(&id).await;
|
||||||
|
return Err(format!("Request failed with status: {}", response.status()));
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get body text via helper
|
||||||
|
let body = get_response_body_text(response).await?;
|
||||||
|
|
||||||
|
// Deserialize server
|
||||||
|
let mut updated_server: Server = serde_json::from_str(&body)
|
||||||
|
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||||
|
|
||||||
|
// Restore local state
|
||||||
|
updated_server.id = id.clone();
|
||||||
|
updated_server.builtin = is_builtin;
|
||||||
|
updated_server.enabled = is_enabled;
|
||||||
|
updated_server.available = true;
|
||||||
|
updated_server.profile = profile;
|
||||||
|
trim_endpoint_last_forward_slash(&mut updated_server);
|
||||||
|
|
||||||
|
// Save and persist
|
||||||
|
save_server(&updated_server);
|
||||||
|
persist_servers(&app_handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to persist servers: {}", e))?;
|
||||||
|
|
||||||
|
// Refresh connectors and datasources (best effort)
|
||||||
|
let _ = fetch_connectors_by_server(&id).await;
|
||||||
|
let _ = datasource_search(&id, None).await;
|
||||||
|
|
||||||
|
Ok(updated_server)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -359,12 +359,10 @@ pub async fn add_coco_server<R: Runtime>(
|
|||||||
) -> Result<Server, String> {
|
) -> Result<Server, String> {
|
||||||
load_or_insert_default_server(&app_handle)
|
load_or_insert_default_server(&app_handle)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to load default servers");
|
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||||
|
|
||||||
// Remove the trailing '/' from the endpoint to ensure correct URL construction
|
|
||||||
let endpoint = endpoint.trim_end_matches('/');
|
let endpoint = endpoint.trim_end_matches('/');
|
||||||
|
|
||||||
// Check if the server with this endpoint already exists
|
|
||||||
if check_endpoint_exists(endpoint) {
|
if check_endpoint_exists(endpoint) {
|
||||||
dbg!(format!(
|
dbg!(format!(
|
||||||
"This Coco server has already been registered: {:?}",
|
"This Coco server has already been registered: {:?}",
|
||||||
@@ -373,59 +371,37 @@ pub async fn add_coco_server<R: Runtime>(
|
|||||||
return Err("This Coco server has already been registered.".into());
|
return Err("This Coco server has already been registered.".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = provider_info_url(&endpoint);
|
let url = provider_info_url(endpoint);
|
||||||
|
|
||||||
// Use the HttpClient to fetch provider information
|
|
||||||
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
|
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||||
|
|
||||||
dbg!(format!("Get provider info response: {:?}", &response));
|
dbg!(format!("Get provider info response: {:?}", &response));
|
||||||
|
|
||||||
// Check if the response status is OK (200)
|
let body = get_response_body_text(response).await?;
|
||||||
if response.status() == StatusCode::OK {
|
|
||||||
if let Some(content_length) = response.content_length() {
|
|
||||||
if content_length > 0 {
|
|
||||||
let new_coco_server: Result<Server, _> = response.json().await;
|
|
||||||
|
|
||||||
match new_coco_server {
|
let mut server: Server = serde_json::from_str(&body)
|
||||||
Ok(mut server) => {
|
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||||
// Perform necessary checks and adjustments on the server data
|
|
||||||
trim_endpoint_last_forward_slash(&mut server);
|
|
||||||
|
|
||||||
if server.id.is_empty() {
|
trim_endpoint_last_forward_slash(&mut server);
|
||||||
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
if server.name.is_empty() {
|
if server.id.is_empty() {
|
||||||
server.name = "Coco Cloud".to_string();
|
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
|
||||||
}
|
|
||||||
|
|
||||||
// Save the new server to the cache
|
|
||||||
save_server(&server);
|
|
||||||
|
|
||||||
// Register the server to the search source
|
|
||||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
|
||||||
|
|
||||||
// Persist the servers to the store
|
|
||||||
persist_servers(&app_handle)
|
|
||||||
.await
|
|
||||||
.expect("Failed to persist Coco servers.");
|
|
||||||
|
|
||||||
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
|
||||||
Ok(server)
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("Failed to deserialize the response: {}", e)),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Received empty response body.".to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err("Could not determine the content length.".to_string())
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Err(format!("Request failed with status: {}", response.status()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if server.name.is_empty() {
|
||||||
|
server.name = "Coco Server".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
save_server(&server);
|
||||||
|
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||||
|
|
||||||
|
persist_servers(&app_handle)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
|
||||||
|
|
||||||
|
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
||||||
|
Ok(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -513,7 +489,7 @@ pub async fn logout_coco_server<R: Runtime>(
|
|||||||
dbg!("Attempting to log out server by id:", &id);
|
dbg!("Attempting to log out server by id:", &id);
|
||||||
|
|
||||||
// Check if server token exists
|
// Check if server token exists
|
||||||
if let Some(_token) = get_server_token(id.as_str()) {
|
if let Some(_token) = get_server_token(id.as_str()).await? {
|
||||||
dbg!("Found server token for id:", &id);
|
dbg!("Found server token for id:", &id);
|
||||||
|
|
||||||
// Remove the server token from cache
|
// Remove the server token from cache
|
||||||
|
|||||||
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,184 +1,132 @@
|
|||||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures::StreamExt;
|
||||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::Emitter;
|
use tauri::{AppHandle, Emitter};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::{mpsc, Mutex};
|
use tokio::sync::{mpsc, Mutex};
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||||
use tokio_tungstenite::tungstenite::Error;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
use tokio_tungstenite::WebSocketStream;
|
||||||
use tokio_tungstenite::{
|
use tokio_tungstenite::{connect_async, MaybeTlsStream};
|
||||||
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
|
|
||||||
};
|
|
||||||
use tungstenite::handshake::client::generate_key;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct WebSocketManager {
|
pub struct WebSocketManager {
|
||||||
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
|
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
|
||||||
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>,
|
}
|
||||||
|
|
||||||
|
struct WebSocketInstance {
|
||||||
|
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
|
||||||
|
cancel_tx: mpsc::Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to convert the HTTP endpoint to WebSocket endpoint
|
|
||||||
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
||||||
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||||
|
let ws_protocol = if url.scheme() == "https" { "wss://" } else { "ws://" };
|
||||||
|
let host = url.host_str().ok_or("No host found in URL")?;
|
||||||
|
let port = url.port_or_known_default().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||||
|
|
||||||
// Determine WebSocket protocol based on the scheme
|
|
||||||
let ws_protocol = if url.scheme() == "https" {
|
|
||||||
"wss://"
|
|
||||||
} else {
|
|
||||||
"ws://"
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract host and port (if present)
|
|
||||||
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
|
|
||||||
let port = url
|
|
||||||
.port_or_known_default()
|
|
||||||
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
|
||||||
|
|
||||||
// Build WebSocket URL, include the port if not the default
|
|
||||||
let ws_endpoint = if port == 80 || port == 443 {
|
let ws_endpoint = if port == 80 || port == 443 {
|
||||||
format!("{}{}{}", ws_protocol, host, "/ws")
|
format!("{}{}{}", ws_protocol, host, "/ws")
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}:{}/ws", ws_protocol, host, port)
|
format!("{}{}:{}/ws", ws_protocol, host, port)
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(ws_endpoint)
|
Ok(ws_endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to build a HeaderMap from a vector of key-value pairs
|
|
||||||
fn build_header_map(headers: Vec<(String, String)>) -> Result<HeaderMap, String> {
|
|
||||||
let mut header_map = HeaderMap::new();
|
|
||||||
for (key, value) in headers {
|
|
||||||
let header_name = HeaderName::from_bytes(key.as_bytes())
|
|
||||||
.map_err(|e| format!("Invalid header name: {}", e))?;
|
|
||||||
let header_value =
|
|
||||||
HeaderValue::from_str(&value).map_err(|e| format!("Invalid header value: {}", e))?;
|
|
||||||
header_map.insert(header_name, header_value);
|
|
||||||
}
|
|
||||||
Ok(header_map)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect_to_server(
|
pub async fn connect_to_server(
|
||||||
id: String,
|
id: String,
|
||||||
|
client_id: String,
|
||||||
state: tauri::State<'_, WebSocketManager>,
|
state: tauri::State<'_, WebSocketManager>,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: AppHandle,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
// Disconnect any existing connection first
|
let connections_clone = state.connections.clone();
|
||||||
disconnect(state.clone()).await?;
|
|
||||||
|
|
||||||
// Retrieve server details
|
// Disconnect old connection first
|
||||||
let server =
|
disconnect(client_id.clone(), state.clone()).await.ok();
|
||||||
get_server_by_id(id.as_str()).ok_or_else(|| format!("Server with ID {} not found", id))?;
|
|
||||||
let endpoint = convert_to_websocket(server.endpoint.as_str())?;
|
|
||||||
|
|
||||||
// Retrieve the token for the server (token is optional)
|
let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?;
|
||||||
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
|
let endpoint = convert_to_websocket(&server.endpoint)?;
|
||||||
|
let token = get_server_token(&id).await?.map(|t| t.access_token.clone());
|
||||||
|
|
||||||
// Create the WebSocket request
|
|
||||||
let mut request =
|
let mut request =
|
||||||
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
||||||
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
||||||
|
|
||||||
// Add necessary headers
|
request.headers_mut().insert("Connection", "Upgrade".parse().unwrap());
|
||||||
request
|
request.headers_mut().insert("Upgrade", "websocket".parse().unwrap());
|
||||||
.headers_mut()
|
request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
||||||
.insert("Connection", "Upgrade".parse().unwrap());
|
request.headers_mut().insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
||||||
request
|
|
||||||
.headers_mut()
|
|
||||||
.insert("Upgrade", "websocket".parse().unwrap());
|
|
||||||
request
|
|
||||||
.headers_mut()
|
|
||||||
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
|
||||||
request
|
|
||||||
.headers_mut()
|
|
||||||
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
|
||||||
|
|
||||||
// If a token exists, add it to the headers
|
|
||||||
if let Some(token) = token {
|
if let Some(token) = token {
|
||||||
request
|
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
|
||||||
.headers_mut()
|
|
||||||
.insert("X-API-TOKEN", token.parse().unwrap());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Establish the WebSocket connection
|
let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?;
|
||||||
// dbg!(&request);
|
|
||||||
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
|
|
||||||
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
|
|
||||||
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
|
|
||||||
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
|
|
||||||
_ => format!("Unknown error: {:?}", e),
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Create cancellation channel
|
|
||||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||||
|
|
||||||
// Store connection and cancellation sender
|
let instance = Arc::new(WebSocketInstance {
|
||||||
*state.ws_connection.lock().await = Some(ws_remote);
|
ws_connection: Mutex::new(ws_stream),
|
||||||
*state.cancel_tx.lock().await = Some(cancel_tx);
|
cancel_tx,
|
||||||
// Spawn listener task with cancellation
|
});
|
||||||
|
|
||||||
|
// Insert connection into the map (lock is held briefly)
|
||||||
|
{
|
||||||
|
let mut connections = connections_clone.lock().await;
|
||||||
|
connections.insert(client_id.clone(), instance.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn WebSocket handler in a separate task
|
||||||
let app_handle_clone = app_handle.clone();
|
let app_handle_clone = app_handle.clone();
|
||||||
let connection_clone = state.ws_connection.clone();
|
let client_id_clone = client_id.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut connection = connection_clone.lock().await;
|
let ws = &mut *instance.ws_connection.lock().await;
|
||||||
if let Some(ws) = connection.as_mut() {
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = ws.next() => {
|
msg = ws.next() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
//println!("Received message: {}", text);
|
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||||
let _ = app_handle_clone.emit("ws-message", text);
|
},
|
||||||
},
|
Some(Err(_)) | None => {
|
||||||
Some(Err(WsError::ConnectionClosed)) => {
|
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||||
let _ = app_handle_clone.emit("ws-error", id);
|
break;
|
||||||
eprintln!("WebSocket connection closed by the server.");
|
|
||||||
break;
|
|
||||||
},
|
|
||||||
Some(Err(WsError::Protocol(e))) => {
|
|
||||||
let _ = app_handle_clone.emit("ws-error", id);
|
|
||||||
eprintln!("Protocol error: {}", e);
|
|
||||||
break;
|
|
||||||
},
|
|
||||||
Some(Err(WsError::Utf8)) => {
|
|
||||||
let _ = app_handle_clone.emit("ws-error", id);
|
|
||||||
eprintln!("Received invalid UTF-8 data.");
|
|
||||||
break;
|
|
||||||
},
|
|
||||||
Some(Err(_)) => {
|
|
||||||
let _ = app_handle_clone.emit("ws-error", id);
|
|
||||||
eprintln!("WebSocket error encountered.");
|
|
||||||
break;
|
|
||||||
},
|
|
||||||
_ => continue,
|
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
_ = cancel_rx.recv() => {
|
}
|
||||||
let _ = app_handle_clone.emit("ws-error", id);
|
_ = cancel_rx.recv() => {
|
||||||
dbg!("Cancelling WebSocket connection");
|
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove connection after it closes
|
||||||
|
let mut connections = connections_clone.lock().await;
|
||||||
|
connections.remove(&client_id_clone);
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
|
||||||
// Send cancellation signal
|
|
||||||
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
|
|
||||||
let _ = cancel_tx.send(()).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close connection
|
#[tauri::command]
|
||||||
let mut connection = state.ws_connection.lock().await;
|
pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||||
if let Some(mut ws) = connection.take() {
|
let instance = {
|
||||||
|
let mut connections = state.connections.lock().await;
|
||||||
|
connections.remove(&client_id)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(instance) = instance {
|
||||||
|
let _ = instance.cancel_tx.send(()).await;
|
||||||
|
|
||||||
|
// Close WebSocket (lock only the connection, not the whole map)
|
||||||
|
let mut ws = instance.ws_connection.lock().await;
|
||||||
let _ = ws.close(None).await;
|
let _ = ws.close(None).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||||
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
|
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
|
||||||
use tauri_nspanel::{
|
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
|
||||||
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
|
|
||||||
panel_delegate, WebviewWindowExt,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::common::MAIN_WINDOW_LABEL;
|
use crate::common::MAIN_WINDOW_LABEL;
|
||||||
|
|
||||||
@@ -22,7 +19,7 @@ pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: Web
|
|||||||
let panel = main_window.to_panel().unwrap();
|
let panel = main_window.to_panel().unwrap();
|
||||||
|
|
||||||
// Make the window above the dock
|
// Make the window above the dock
|
||||||
panel.set_level(NSMainMenuWindowLevel + 1);
|
panel.set_level(20);
|
||||||
|
|
||||||
// Do not steal focus from other windows
|
// Do not steal focus from other windows
|
||||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
use tauri::{App, WebviewWindow};
|
use tauri::{App, WebviewWindow};
|
||||||
|
|
||||||
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {}
|
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {}
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
use crate::{move_window_to_active_monitor, COCO_TAURI_STORE};
|
use crate::{hide_coco, show_coco, COCO_TAURI_STORE};
|
||||||
use tauri::App;
|
use tauri::{async_runtime, App, AppHandle, Manager, Runtime};
|
||||||
use tauri::AppHandle;
|
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||||
use tauri::Manager;
|
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||||
use tauri::Runtime;
|
|
||||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
|
||||||
use tauri_plugin_global_shortcut::Shortcut;
|
|
||||||
use tauri_plugin_global_shortcut::ShortcutState;
|
|
||||||
use tauri_plugin_store::JsonValue;
|
|
||||||
use tauri_plugin_store::StoreExt;
|
|
||||||
|
|
||||||
/// Tauri's store is a key-value database, we use it to store our registered
|
/// Tauri's store is a key-value database, we use it to store our registered
|
||||||
/// global shortcut.
|
/// global shortcut.
|
||||||
@@ -54,14 +48,14 @@ pub fn enable_shortcut(app: &App) {
|
|||||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||||
/// this is a `tauri::command` interface.
|
/// this is a `tauri::command` interface.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||||
let shortcut = _get_shortcut(&app);
|
let shortcut = _get_shortcut(&app);
|
||||||
Ok(shortcut)
|
Ok(shortcut)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current shortcut and unregister it on the tauri side.
|
/// Get the current shortcut and unregister it on the tauri side.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||||
let shortcut_str = _get_shortcut(&app);
|
let shortcut_str = _get_shortcut(&app);
|
||||||
let shortcut = shortcut_str
|
let shortcut = shortcut_str
|
||||||
.parse::<Shortcut>()
|
.parse::<Shortcut>()
|
||||||
@@ -74,7 +68,7 @@ pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
|||||||
|
|
||||||
/// Change the global shortcut to `key`.
|
/// Change the global shortcut to `key`.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn change_shortcut<R: Runtime>(
|
pub async fn change_shortcut<R: Runtime>(
|
||||||
app: AppHandle<R>,
|
app: AppHandle<R>,
|
||||||
_window: tauri::Window<R>,
|
_window: tauri::Window<R>,
|
||||||
key: String,
|
key: String,
|
||||||
@@ -105,16 +99,15 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
|||||||
dbg!("shortcut pressed");
|
dbg!("shortcut pressed");
|
||||||
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||||
if let ShortcutState::Pressed = event.state() {
|
if let ShortcutState::Pressed = event.state() {
|
||||||
|
let app_handle = app.clone();
|
||||||
if main_window.is_visible().unwrap() {
|
if main_window.is_visible().unwrap() {
|
||||||
dbg!("hiding window");
|
async_runtime::spawn(async move {
|
||||||
main_window.hide().unwrap();
|
hide_coco(app_handle).await;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
dbg!("showing window");
|
async_runtime::spawn(async move {
|
||||||
move_window_to_active_monitor(&main_window);
|
show_coco(app_handle).await;
|
||||||
main_window.set_visible_on_all_workspaces(true).unwrap();
|
});
|
||||||
main_window.set_always_on_top(true).unwrap();
|
|
||||||
main_window.set_focus().unwrap();
|
|
||||||
main_window.show().unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,15 +128,16 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
|||||||
if scut == &shortcut {
|
if scut == &shortcut {
|
||||||
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||||
if let ShortcutState::Pressed = event.state() {
|
if let ShortcutState::Pressed = event.state() {
|
||||||
|
let app_handle = app.clone();
|
||||||
|
|
||||||
if window.is_visible().unwrap() {
|
if window.is_visible().unwrap() {
|
||||||
window.hide().unwrap();
|
async_runtime::spawn(async move {
|
||||||
|
hide_coco(app_handle).await;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// dbg!("showing window");
|
async_runtime::spawn(async move {
|
||||||
move_window_to_active_monitor(&window);
|
show_coco(app_handle).await;
|
||||||
window.set_visible_on_all_workspaces(true).unwrap();
|
});
|
||||||
window.set_always_on_top(true).unwrap();
|
|
||||||
window.set_focus().unwrap();
|
|
||||||
window.show().unwrap();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
"visible": false,
|
||||||
"windowEffects": {
|
"windowEffects": {
|
||||||
"effects": [],
|
"effects": [],
|
||||||
"radius": 12
|
"radius": 6
|
||||||
}
|
},
|
||||||
|
"visibleOnAllWorkspaces": true,
|
||||||
|
"alwaysOnTop": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "settings",
|
"label": "settings",
|
||||||
|
|||||||
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 { fetch } from "@tauri-apps/plugin-http";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
import { clientEnv } from "@/utils/env";
|
import { clientEnv } from "@/utils/env";
|
||||||
import { useLogStore } from "@/stores/logStore";
|
import { useLogStore } from "@/stores/logStore";
|
||||||
|
import { get_server_token } from "@/commands";
|
||||||
interface FetchRequestConfig {
|
interface FetchRequestConfig {
|
||||||
url: string;
|
url: string;
|
||||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
@@ -63,8 +62,8 @@ export const tauriFetch = async <T = any>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
|
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
|
||||||
const res: any = await invoke("get_server_token", {id: server_id});
|
const res: any = await get_server_token(server_id);
|
||||||
|
|
||||||
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
|
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
|
||||||
|
|
||||||
// debug API
|
// debug API
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
|
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useSearchStore } from "@/stores/searchStore";
|
|
||||||
import { useWindows } from "@/hooks/useWindows";
|
import { useWindows } from "@/hooks/useWindows";
|
||||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||||
import useWebSocket from "@/hooks/useWebSocket";
|
import useWebSocket from "@/hooks/useWebSocket";
|
||||||
@@ -21,17 +20,22 @@ import { ChatHeader } from "./ChatHeader";
|
|||||||
import { ChatContent } from "./ChatContent";
|
import { ChatContent } from "./ChatContent";
|
||||||
import ConnectPrompt from "./ConnectPrompt";
|
import ConnectPrompt from "./ConnectPrompt";
|
||||||
import type { Chat } from "./types";
|
import type { Chat } from "./types";
|
||||||
|
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
interface ChatAIProps {
|
interface ChatAIProps {
|
||||||
isTransitioned: boolean;
|
|
||||||
isSearchActive?: boolean;
|
isSearchActive?: boolean;
|
||||||
isDeepThinkActive?: boolean;
|
isDeepThinkActive?: boolean;
|
||||||
|
isMCPActive?: boolean;
|
||||||
activeChatProp?: Chat;
|
activeChatProp?: Chat;
|
||||||
changeInput?: (val: string) => void;
|
changeInput?: (val: string) => void;
|
||||||
setIsSidebarOpen?: (value: boolean) => void;
|
setIsSidebarOpen?: (value: boolean) => void;
|
||||||
isSidebarOpen?: boolean;
|
isSidebarOpen?: boolean;
|
||||||
clearChatPage?: () => void;
|
clearChatPage?: () => void;
|
||||||
isChatPage?: boolean;
|
isChatPage?: boolean;
|
||||||
|
getFileUrl: (path: string) => string;
|
||||||
|
showChatHistory?: boolean;
|
||||||
|
assistantIDs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatAIRef {
|
export interface ChatAIRef {
|
||||||
@@ -45,20 +49,21 @@ const ChatAI = memo(
|
|||||||
forwardRef<ChatAIRef, ChatAIProps>(
|
forwardRef<ChatAIRef, ChatAIProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
isTransitioned,
|
|
||||||
changeInput,
|
changeInput,
|
||||||
isSearchActive,
|
isSearchActive,
|
||||||
isDeepThinkActive,
|
isDeepThinkActive,
|
||||||
|
isMCPActive,
|
||||||
activeChatProp,
|
activeChatProp,
|
||||||
setIsSidebarOpen,
|
setIsSidebarOpen,
|
||||||
isSidebarOpen = false,
|
isSidebarOpen = false,
|
||||||
clearChatPage,
|
clearChatPage,
|
||||||
isChatPage = false,
|
isChatPage = false,
|
||||||
|
getFileUrl,
|
||||||
|
showChatHistory,
|
||||||
|
assistantIDs,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
if (!isTransitioned) return null;
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
init: init,
|
init: init,
|
||||||
cancelChat: () => cancelChat(activeChat),
|
cancelChat: () => cancelChat(activeChat),
|
||||||
@@ -70,6 +75,11 @@ const ChatAI = memo(
|
|||||||
useChatStore();
|
useChatStore();
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
const currentService = useConnectStore((state) => state.currentService);
|
||||||
|
const visibleStartPage = useConnectStore((state) => {
|
||||||
|
return state.visibleStartPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
const addError = useAppStore.getState().addError;
|
||||||
|
|
||||||
const [activeChat, setActiveChat] = useState<Chat>();
|
const [activeChat, setActiveChat] = useState<Chat>();
|
||||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||||
@@ -79,7 +89,6 @@ const ChatAI = memo(
|
|||||||
|
|
||||||
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
||||||
const [chats, setChats] = useState<Chat[]>([]);
|
const [chats, setChats] = useState<Chat[]>([]);
|
||||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatProp && setActiveChat(activeChatProp);
|
activeChatProp && setActiveChat(activeChatProp);
|
||||||
@@ -87,9 +96,16 @@ const ChatAI = memo(
|
|||||||
|
|
||||||
const [Question, setQuestion] = useState<string>("");
|
const [Question, setQuestion] = useState<string>("");
|
||||||
|
|
||||||
|
const [websocketSessionId, setWebsocketSessionId] = useState("");
|
||||||
|
|
||||||
|
const onWebsocketSessionId = useCallback((sessionId: string) => {
|
||||||
|
setWebsocketSessionId(sessionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
query_intent,
|
query_intent,
|
||||||
|
tools,
|
||||||
fetch_source,
|
fetch_source,
|
||||||
pick_source,
|
pick_source,
|
||||||
deep_read,
|
deep_read,
|
||||||
@@ -102,6 +118,7 @@ const ChatAI = memo(
|
|||||||
|
|
||||||
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
|
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
|
||||||
query_intent: false,
|
query_intent: false,
|
||||||
|
tools: false,
|
||||||
fetch_source: false,
|
fetch_source: false,
|
||||||
pick_source: false,
|
pick_source: false,
|
||||||
deep_read: false,
|
deep_read: false,
|
||||||
@@ -111,13 +128,15 @@ const ChatAI = memo(
|
|||||||
|
|
||||||
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
|
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
|
||||||
|
|
||||||
const { errorShow, setErrorShow, reconnect, updateDealMsg } =
|
const clientId = isChatPage ? "standalone" : "popup";
|
||||||
useWebSocket({
|
const { reconnect, disconnectWS, updateDealMsg } = useWebSocket({
|
||||||
connected,
|
clientId,
|
||||||
setConnected,
|
connected,
|
||||||
currentService,
|
setConnected,
|
||||||
dealMsgRef,
|
currentService,
|
||||||
});
|
dealMsgRef,
|
||||||
|
onWebsocketSessionId,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
chatClose,
|
chatClose,
|
||||||
@@ -128,19 +147,24 @@ const ChatAI = memo(
|
|||||||
openSessionChat,
|
openSessionChat,
|
||||||
getChatHistory,
|
getChatHistory,
|
||||||
createChatWindow,
|
createChatWindow,
|
||||||
|
handleSearch,
|
||||||
|
handleRename,
|
||||||
|
handleDelete,
|
||||||
} = useChatActions(
|
} = useChatActions(
|
||||||
currentService?.id,
|
currentService?.id,
|
||||||
setActiveChat,
|
setActiveChat,
|
||||||
setCurChatEnd,
|
setCurChatEnd,
|
||||||
setErrorShow,
|
|
||||||
setTimedoutShow,
|
setTimedoutShow,
|
||||||
clearAllChunkData,
|
clearAllChunkData,
|
||||||
setQuestion,
|
setQuestion,
|
||||||
curIdRef,
|
curIdRef,
|
||||||
|
setChats,
|
||||||
isSearchActive,
|
isSearchActive,
|
||||||
isDeepThinkActive,
|
isDeepThinkActive,
|
||||||
sourceDataIds,
|
isMCPActive,
|
||||||
changeInput
|
changeInput,
|
||||||
|
websocketSessionId,
|
||||||
|
showChatHistory,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { dealMsg, messageTimeoutRef } = useMessageHandler(
|
const { dealMsg, messageTimeoutRef } = useMessageHandler(
|
||||||
@@ -162,7 +186,6 @@ const ChatAI = memo(
|
|||||||
const clearChat = useCallback(() => {
|
const clearChat = useCallback(() => {
|
||||||
console.log("clearChat");
|
console.log("clearChat");
|
||||||
setTimedoutShow(false);
|
setTimedoutShow(false);
|
||||||
setErrorShow(false);
|
|
||||||
chatClose(activeChat);
|
chatClose(activeChat);
|
||||||
setActiveChat(undefined);
|
setActiveChat(undefined);
|
||||||
setCurChatEnd(true);
|
setCurChatEnd(true);
|
||||||
@@ -172,21 +195,38 @@ const ChatAI = memo(
|
|||||||
chatClose,
|
chatClose,
|
||||||
clearChatPage,
|
clearChatPage,
|
||||||
setCurChatEnd,
|
setCurChatEnd,
|
||||||
setErrorShow,
|
|
||||||
setTimedoutShow,
|
setTimedoutShow,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const init = useCallback(
|
const init = useCallback(
|
||||||
(value: string) => {
|
async (value: string) => {
|
||||||
if (!isLogin) return;
|
try {
|
||||||
if (!curChatEnd) return;
|
console.log("init", isLogin, curChatEnd, activeChat?._id);
|
||||||
if (!activeChat?._id) {
|
if (!isLogin) {
|
||||||
createNewChat(value, activeChat);
|
addError("Please login to continue chatting");
|
||||||
} else {
|
return;
|
||||||
handleSendMessage(value, activeChat);
|
}
|
||||||
|
if (!curChatEnd) {
|
||||||
|
addError("Please wait for the current conversation to complete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!activeChat?._id) {
|
||||||
|
await createNewChat(value, activeChat, websocketSessionId);
|
||||||
|
} else {
|
||||||
|
await handleSendMessage(value, activeChat, websocketSessionId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to initialize chat:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage]
|
[
|
||||||
|
isLogin,
|
||||||
|
curChatEnd,
|
||||||
|
activeChat,
|
||||||
|
createNewChat,
|
||||||
|
handleSendMessage,
|
||||||
|
websocketSessionId,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const { createWin } = useWindows();
|
const { createWin } = useWindows();
|
||||||
@@ -195,20 +235,23 @@ const ChatAI = memo(
|
|||||||
}, [createChatWindow, createWin]);
|
}, [createChatWindow, createWin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setCurChatEnd(true);
|
||||||
return () => {
|
return () => {
|
||||||
if (messageTimeoutRef.current) {
|
if (messageTimeoutRef.current) {
|
||||||
clearTimeout(messageTimeoutRef.current);
|
clearTimeout(messageTimeoutRef.current);
|
||||||
}
|
}
|
||||||
chatClose(activeChat);
|
Promise.resolve().then(() => {
|
||||||
setActiveChat(undefined);
|
chatClose(activeChat);
|
||||||
setCurChatEnd(true);
|
setActiveChat(undefined);
|
||||||
|
setCurChatEnd(true);
|
||||||
|
disconnectWS();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, [chatClose, setCurChatEnd]);
|
}, [chatClose, setCurChatEnd]);
|
||||||
|
|
||||||
const onSelectChat = useCallback(
|
const onSelectChat = useCallback(
|
||||||
async (chat: Chat) => {
|
async (chat: Chat) => {
|
||||||
setTimedoutShow(false);
|
setTimedoutShow(false);
|
||||||
setErrorShow(false);
|
|
||||||
clearAllChunkData();
|
clearAllChunkData();
|
||||||
await cancelChat(activeChat);
|
await cancelChat(activeChat);
|
||||||
await chatClose(activeChat);
|
await chatClose(activeChat);
|
||||||
@@ -227,17 +270,24 @@ const ChatAI = memo(
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteChat = useCallback((chatId: string) => {
|
const deleteChat = useCallback(
|
||||||
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
(chatId: string) => {
|
||||||
if (activeChat?._id === chatId) {
|
handleDelete(chatId);
|
||||||
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
|
||||||
if (remainingChats.length > 0) {
|
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
||||||
setActiveChat(remainingChats[0]);
|
|
||||||
} else {
|
if (activeChat?._id === chatId) {
|
||||||
init("");
|
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
||||||
|
|
||||||
|
if (remainingChats.length > 0) {
|
||||||
|
setActiveChat(remainingChats[0]);
|
||||||
|
} else {
|
||||||
|
init("");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}, [activeChat, chats, init, setActiveChat]);
|
[activeChat, chats, init, setActiveChat]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
||||||
const sidebar = document.querySelector("[data-sidebar]");
|
const sidebar = document.querySelector("[data-sidebar]");
|
||||||
@@ -261,40 +311,61 @@ const ChatAI = memo(
|
|||||||
};
|
};
|
||||||
}, [isSidebarOpenChat, handleOutsideClick]);
|
}, [isSidebarOpenChat, handleOutsideClick]);
|
||||||
|
|
||||||
const fetchChatHistory = useCallback(async () => {
|
|
||||||
const hits = await getChatHistory();
|
|
||||||
setChats(hits);
|
|
||||||
}, [getChatHistory]);
|
|
||||||
|
|
||||||
const setIsLoginChat = useCallback(
|
|
||||||
(value: boolean) => {
|
|
||||||
setIsLogin(value);
|
|
||||||
value && currentService && !setIsSidebarOpen && fetchChatHistory();
|
|
||||||
!value && setChats([]);
|
|
||||||
},
|
|
||||||
[currentService, setIsSidebarOpen, fetchChatHistory]
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleSidebar = useCallback(() => {
|
const toggleSidebar = useCallback(() => {
|
||||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||||
!isSidebarOpenChat && fetchChatHistory();
|
!isSidebarOpenChat && getChatHistory();
|
||||||
}, [isSidebarOpenChat, setIsSidebarOpen, fetchChatHistory]);
|
}, [isSidebarOpenChat, setIsSidebarOpen, getChatHistory]);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={`h-full flex flex-col rounded-xl overflow-hidden`}
|
className={`h-full flex flex-col rounded-md relative`}
|
||||||
>
|
>
|
||||||
{!setIsSidebarOpen && (
|
{showChatHistory && !setIsSidebarOpen && (
|
||||||
<ChatSidebar
|
<ChatSidebar
|
||||||
isSidebarOpen={isSidebarOpenChat}
|
isSidebarOpen={isSidebarOpenChat}
|
||||||
chats={chats}
|
chats={chats}
|
||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
onNewChat={clearChat}
|
// onNewChat={clearChat}
|
||||||
onSelectChat={onSelectChat}
|
onSelectChat={onSelectChat}
|
||||||
onDeleteChat={deleteChat}
|
onDeleteChat={deleteChat}
|
||||||
fetchChatHistory={fetchChatHistory}
|
fetchChatHistory={getChatHistory}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onRename={renameChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -306,13 +377,17 @@ const ChatAI = memo(
|
|||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
reconnect={reconnect}
|
reconnect={reconnect}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
setIsLogin={setIsLoginChat}
|
isLogin={isLogin}
|
||||||
|
setIsLogin={setIsLogin}
|
||||||
|
showChatHistory={showChatHistory}
|
||||||
|
assistantIDs={assistantIDs}
|
||||||
/>
|
/>
|
||||||
{isLogin ? (
|
{isLogin ? (
|
||||||
<ChatContent
|
<ChatContent
|
||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
curChatEnd={curChatEnd}
|
curChatEnd={curChatEnd}
|
||||||
query_intent={query_intent}
|
query_intent={query_intent}
|
||||||
|
tools={tools}
|
||||||
fetch_source={fetch_source}
|
fetch_source={fetch_source}
|
||||||
pick_source={pick_source}
|
pick_source={pick_source}
|
||||||
deep_read={deep_read}
|
deep_read={deep_read}
|
||||||
@@ -320,13 +395,19 @@ const ChatAI = memo(
|
|||||||
response={response}
|
response={response}
|
||||||
loadingStep={loadingStep}
|
loadingStep={loadingStep}
|
||||||
timedoutShow={timedoutShow}
|
timedoutShow={timedoutShow}
|
||||||
errorShow={errorShow}
|
|
||||||
Question={Question}
|
Question={Question}
|
||||||
handleSendMessage={(value) => handleSendMessage(value, activeChat)}
|
handleSendMessage={(value) =>
|
||||||
|
handleSendMessage(value, activeChat)
|
||||||
|
}
|
||||||
|
getFileUrl={getFileUrl}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConnectPrompt />
|
<ConnectPrompt />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!activeChat?._id && !visibleStartPage && (
|
||||||
|
<PrevSuggestion sendMessage={init} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
import { ChatMessage } from "@/components/ChatMessage";
|
import { ChatMessage } from "@/components/ChatMessage";
|
||||||
import { Greetings } from "./Greetings";
|
import { Greetings } from "./Greetings";
|
||||||
import FileList from "@/components/Search/FileList";
|
import FileList from "@/components/Assistant/FileList";
|
||||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
import { useChatStore } from "@/stores/chatStore";
|
||||||
import type { Chat, IChunkData } from "./types";
|
import type { Chat, IChunkData } from "./types";
|
||||||
|
// import SessionFile from "./SessionFile";
|
||||||
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
|
import SessionFile from "./SessionFile";
|
||||||
|
import Splash from "./Splash";
|
||||||
|
|
||||||
interface ChatContentProps {
|
interface ChatContentProps {
|
||||||
activeChat?: Chat;
|
activeChat?: Chat;
|
||||||
curChatEnd: boolean;
|
curChatEnd: boolean;
|
||||||
query_intent?: IChunkData;
|
query_intent?: IChunkData;
|
||||||
|
tools?: IChunkData;
|
||||||
fetch_source?: IChunkData;
|
fetch_source?: IChunkData;
|
||||||
pick_source?: IChunkData;
|
pick_source?: IChunkData;
|
||||||
deep_read?: IChunkData;
|
deep_read?: IChunkData;
|
||||||
@@ -19,15 +24,16 @@ interface ChatContentProps {
|
|||||||
response?: IChunkData;
|
response?: IChunkData;
|
||||||
loadingStep?: Record<string, boolean>;
|
loadingStep?: Record<string, boolean>;
|
||||||
timedoutShow: boolean;
|
timedoutShow: boolean;
|
||||||
errorShow: boolean;
|
|
||||||
Question: string;
|
Question: string;
|
||||||
handleSendMessage: (content: string, newChat?: Chat) => void;
|
handleSendMessage: (content: string, newChat?: Chat) => void;
|
||||||
|
getFileUrl: (path: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatContent = ({
|
export const ChatContent = ({
|
||||||
activeChat,
|
activeChat,
|
||||||
curChatEnd,
|
curChatEnd,
|
||||||
query_intent,
|
query_intent,
|
||||||
|
tools,
|
||||||
fetch_source,
|
fetch_source,
|
||||||
pick_source,
|
pick_source,
|
||||||
deep_read,
|
deep_read,
|
||||||
@@ -35,16 +41,25 @@ export const ChatContent = ({
|
|||||||
response,
|
response,
|
||||||
loadingStep,
|
loadingStep,
|
||||||
timedoutShow,
|
timedoutShow,
|
||||||
errorShow,
|
|
||||||
Question,
|
Question,
|
||||||
handleSendMessage,
|
handleSendMessage,
|
||||||
|
getFileUrl,
|
||||||
}: ChatContentProps) => {
|
}: ChatContentProps) => {
|
||||||
|
const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||||
|
const setCurrentSessionId = useConnectStore((state) => {
|
||||||
|
return state.setCurrentSessionId;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentSessionId(activeChat?._id);
|
||||||
|
}, [activeChat]);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,7 +82,7 @@ export const ChatContent = ({
|
|||||||
}, [scrollToBottom]);
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full justify-between overflow-hidden">
|
<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">
|
<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 />
|
<Greetings />
|
||||||
|
|
||||||
@@ -81,6 +96,7 @@ export const ChatContent = ({
|
|||||||
))}
|
))}
|
||||||
{(!curChatEnd ||
|
{(!curChatEnd ||
|
||||||
query_intent ||
|
query_intent ||
|
||||||
|
tools ||
|
||||||
fetch_source ||
|
fetch_source ||
|
||||||
pick_source ||
|
pick_source ||
|
||||||
deep_read ||
|
deep_read ||
|
||||||
@@ -100,6 +116,7 @@ export const ChatContent = ({
|
|||||||
onResend={handleSendMessage}
|
onResend={handleSendMessage}
|
||||||
isTyping={!curChatEnd}
|
isTyping={!curChatEnd}
|
||||||
query_intent={query_intent}
|
query_intent={query_intent}
|
||||||
|
tools={tools}
|
||||||
fetch_source={fetch_source}
|
fetch_source={fetch_source}
|
||||||
pick_source={pick_source}
|
pick_source={pick_source}
|
||||||
deep_read={deep_read}
|
deep_read={deep_read}
|
||||||
@@ -123,29 +140,18 @@ export const ChatContent = ({
|
|||||||
isTyping={false}
|
isTyping={false}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{uploadFiles.length > 0 && (
|
{sessionId && uploadFiles.length > 0 && (
|
||||||
<div className="max-h-[120px] overflow-auto p-2">
|
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
||||||
<FileList />
|
<FileList sessionId={sessionId} getFileUrl={getFileUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sessionId && <SessionFile sessionId={sessionId} />}
|
||||||
|
|
||||||
|
<Splash />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,36 +1,18 @@
|
|||||||
import {
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
MessageSquarePlus,
|
import clsx from "clsx";
|
||||||
ChevronDownIcon,
|
|
||||||
Settings,
|
|
||||||
RefreshCw,
|
|
||||||
Check,
|
|
||||||
Server,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
Menu,
|
|
||||||
MenuButton,
|
|
||||||
MenuItem,
|
|
||||||
MenuItems,
|
|
||||||
Popover,
|
|
||||||
PopoverButton,
|
|
||||||
PopoverPanel,
|
|
||||||
} from "@headlessui/react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { emit, listen } from "@tauri-apps/api/event";
|
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
||||||
|
|
||||||
import logoImg from "@/assets/icon.svg";
|
|
||||||
import HistoryIcon from "@/icons/History";
|
import HistoryIcon from "@/icons/History";
|
||||||
import PinOffIcon from "@/icons/PinOff";
|
import PinOffIcon from "@/icons/PinOff";
|
||||||
import PinIcon from "@/icons/Pin";
|
import PinIcon from "@/icons/Pin";
|
||||||
import ServerIcon from "@/icons/Server";
|
|
||||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||||
import { useAppStore, IServer } from "@/stores/appStore";
|
import { useAppStore, IServer } from "@/stores/appStore";
|
||||||
import { useChatStore } from "@/stores/chatStore";
|
|
||||||
import type { Chat } from "./types";
|
import type { Chat } from "./types";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
import VisibleKey from "../Common/VisibleKey";
|
||||||
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
import { HISTORY_PANEL_ID } from "@/constants";
|
||||||
|
import { AssistantList } from "./AssistantList";
|
||||||
|
import { ServerList } from "./ServerList";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
onCreateNewChat: () => void;
|
onCreateNewChat: () => void;
|
||||||
@@ -39,110 +21,46 @@ interface ChatHeaderProps {
|
|||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
activeChat: Chat | undefined;
|
activeChat: Chat | undefined;
|
||||||
reconnect: (server?: IServer) => void;
|
reconnect: (server?: IServer) => void;
|
||||||
|
isLogin: boolean;
|
||||||
setIsLogin: (isLogin: boolean) => void;
|
setIsLogin: (isLogin: boolean) => void;
|
||||||
isChatPage?: boolean;
|
isChatPage?: boolean;
|
||||||
|
showChatHistory?: boolean;
|
||||||
|
assistantIDs?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({
|
export function ChatHeader({
|
||||||
onCreateNewChat,
|
onCreateNewChat,
|
||||||
onOpenChatAI,
|
onOpenChatAI,
|
||||||
|
isSidebarOpen,
|
||||||
setIsSidebarOpen,
|
setIsSidebarOpen,
|
||||||
activeChat,
|
activeChat,
|
||||||
reconnect,
|
reconnect,
|
||||||
|
isLogin,
|
||||||
setIsLogin,
|
setIsLogin,
|
||||||
isChatPage = false,
|
isChatPage = false,
|
||||||
|
showChatHistory = true,
|
||||||
|
assistantIDs,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
|
||||||
const isPinned = useAppStore((state) => state.isPinned);
|
const isPinned = useAppStore((state) => state.isPinned);
|
||||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||||
|
|
||||||
const { connected, setMessages } = useChatStore();
|
const isTauri = useAppStore((state) => state.isTauri);
|
||||||
|
const historicalRecords = useShortcutsStore((state) => {
|
||||||
|
return state.historicalRecords;
|
||||||
|
});
|
||||||
|
const newSession = useShortcutsStore((state) => {
|
||||||
|
return state.newSession;
|
||||||
|
});
|
||||||
|
const fixedWindow = useShortcutsStore((state) => {
|
||||||
|
return state.fixedWindow;
|
||||||
|
});
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
const external = useShortcutsStore((state) => state.external);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const currentService = useConnectStore((state) => state.currentService);
|
|
||||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
|
||||||
|
|
||||||
const fetchServers = useCallback(async (resetSelection: boolean) => {
|
|
||||||
invoke("list_coco_servers")
|
|
||||||
.then((res: any) => {
|
|
||||||
const enabledServers = (res as IServer[]).filter(
|
|
||||||
(server) => server.enabled !== false
|
|
||||||
);
|
|
||||||
//console.log("list_coco_servers", enabledServers);
|
|
||||||
setServerList(enabledServers);
|
|
||||||
|
|
||||||
if (resetSelection && enabledServers.length > 0) {
|
|
||||||
const currentServiceExists = enabledServers.find(
|
|
||||||
(server) => server.id === currentService?.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentServiceExists) {
|
|
||||||
switchServer(currentServiceExists);
|
|
||||||
} else {
|
|
||||||
switchServer(enabledServers[enabledServers.length - 1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
}, [currentService?.id]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchServers(true);
|
|
||||||
|
|
||||||
const unlisten = listen("login_or_logout", (event) => {
|
|
||||||
console.log("Login or Logout:", currentService, event);
|
|
||||||
fetchServers(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup logic if needed
|
|
||||||
disconnect();
|
|
||||||
unlisten.then((fn) => fn());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const disconnect = async () => {
|
|
||||||
if (!connected) return;
|
|
||||||
try {
|
|
||||||
console.log("disconnect");
|
|
||||||
await invoke("disconnect");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to disconnect:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const switchServer = async (server: IServer) => {
|
|
||||||
if (!server) return;
|
|
||||||
try {
|
|
||||||
// Switch UI first, then switch server connection
|
|
||||||
setCurrentService(server);
|
|
||||||
setEndpoint(server.endpoint);
|
|
||||||
setMessages(""); // Clear previous messages
|
|
||||||
onCreateNewChat();
|
|
||||||
//
|
|
||||||
if (!server.public && !server.profile) {
|
|
||||||
setIsLogin(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLogin(true);
|
|
||||||
//
|
|
||||||
await disconnect();
|
|
||||||
reconnect && reconnect(server);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("switchServer:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePin = async () => {
|
const togglePin = async () => {
|
||||||
try {
|
try {
|
||||||
const newPinned = !isPinned;
|
const newPinned = !isPinned;
|
||||||
await getCurrentWindow().setAlwaysOnTop(newPinned);
|
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||||
setIsPinned(newPinned);
|
setIsPinned(newPinned);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to toggle window pin state:", err);
|
console.error("Failed to toggle window pin state:", err);
|
||||||
@@ -150,183 +68,81 @@ export function ChatHeader({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSettings = async () => {
|
|
||||||
emit("open_settings", "connect");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="flex items-center justify-between py-2 px-3"
|
className="flex items-center justify-between py-2 px-3 select-none"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{showChatHistory && (
|
||||||
data-sidebar-button
|
<button
|
||||||
onClick={(e) => {
|
data-sidebar-button
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setIsSidebarOpen();
|
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}
|
||||||
<HistoryIcon />
|
className="py-1 px-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
<MenuItem>
|
<VisibleKey
|
||||||
<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">
|
shortcut={historicalRecords}
|
||||||
<img
|
onKeyPress={setIsSidebarOpen}
|
||||||
src={logoImg}
|
>
|
||||||
className="w-4 h-4"
|
<HistoryIcon className="h-4 w-4" />
|
||||||
alt={t("assistant.message.logo")}
|
</VisibleKey>
|
||||||
/>
|
|
||||||
Coco AI
|
|
||||||
</button>
|
|
||||||
</MenuItem>
|
|
||||||
</MenuItems>
|
|
||||||
</Menu>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={onCreateNewChat}
|
|
||||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
<MessageSquarePlus className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
|
||||||
{activeChat?._source?.title ||
|
|
||||||
activeChat?._source?.message ||
|
|
||||||
activeChat?._id}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={togglePin}
|
|
||||||
className={`${isPinned ? "text-blue-500" : ""}`}
|
|
||||||
>
|
|
||||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Popover className="relative">
|
|
||||||
<PopoverButton className="flex items-center">
|
|
||||||
<ServerIcon />
|
|
||||||
</PopoverButton>
|
|
||||||
|
|
||||||
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="p-3">
|
|
||||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
Servers
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={openSettings}
|
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
await fetchServers(false);
|
|
||||||
setTimeout(() => setIsRefreshing(false), 1000);
|
|
||||||
}}
|
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
|
||||||
disabled={isRefreshing}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
|
||||||
isRefreshing ? "animate-spin" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{serverList.length > 0 ? (
|
|
||||||
serverList.map((server) => (
|
|
||||||
<div
|
|
||||||
key={server.id}
|
|
||||||
onClick={() => switchServer(server)}
|
|
||||||
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
|
|
||||||
currentService?.id === server.id
|
|
||||||
? "bg-gray-100 dark:bg-gray-800"
|
|
||||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
|
||||||
<img
|
|
||||||
src={server?.provider?.icon || logoImg}
|
|
||||||
alt={server.name}
|
|
||||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
|
||||||
/>
|
|
||||||
<div className="text-left flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
|
||||||
{server.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
|
||||||
AI Assistant: {server.assistantCount || 1}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`w-3 h-3 rounded-full ${
|
|
||||||
server.health?.status
|
|
||||||
? `bg-[${server.health?.status}]`
|
|
||||||
: "bg-gray-400 dark:bg-gray-600"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="w-4 h-4">
|
|
||||||
{currentService?.id === server.id && (
|
|
||||||
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
|
||||||
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t("assistant.chat.noServers")}
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={openSettings}
|
|
||||||
className="mt-2 text-xs text-[#0287FF] hover:underline"
|
|
||||||
>
|
|
||||||
{t("assistant.chat.addServer")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverPanel>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{isChatPage ? null : (
|
|
||||||
<button onClick={onOpenChatAI}>
|
|
||||||
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AssistantList assistantIDs={assistantIDs} />
|
||||||
|
|
||||||
|
{showChatHistory ? (
|
||||||
|
<button
|
||||||
|
onClick={onCreateNewChat}
|
||||||
|
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
|
||||||
|
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||||
|
</VisibleKey>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{activeChat?._source?.title ||
|
||||||
|
activeChat?._source?.message ||
|
||||||
|
activeChat?._id}
|
||||||
|
</h2>
|
||||||
|
{isTauri ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={togglePin}
|
||||||
|
className={clsx("inline-flex", {
|
||||||
|
"text-blue-500": isPinned,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||||
|
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||||
|
</VisibleKey>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ServerList
|
||||||
|
isLogin={isLogin}
|
||||||
|
setIsLogin={setIsLogin}
|
||||||
|
reconnect={reconnect}
|
||||||
|
onCreateNewChat={onCreateNewChat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isChatPage ? null : (
|
||||||
|
<button className="inline-flex" onClick={onOpenChatAI}>
|
||||||
|
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
|
||||||
|
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
|
||||||
|
</VisibleKey>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,65 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
// import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||||
import type { Chat } from "./types";
|
import type { Chat } from "./types";
|
||||||
|
import HistoryList from "../Common/HistoryList";
|
||||||
|
import { HISTORY_PANEL_ID } from "@/constants";
|
||||||
|
|
||||||
interface ChatSidebarProps {
|
interface ChatSidebarProps {
|
||||||
isSidebarOpen: boolean;
|
isSidebarOpen: boolean;
|
||||||
chats: Chat[];
|
chats: Chat[];
|
||||||
activeChat?: Chat;
|
activeChat?: Chat;
|
||||||
onNewChat: () => void;
|
// onNewChat: () => void;
|
||||||
onSelectChat: (chat: any) => void;
|
onSelectChat: (chat: any) => void;
|
||||||
onDeleteChat: (chatId: string) => void;
|
onDeleteChat: (chatId: string) => void;
|
||||||
fetchChatHistory: () => void;
|
fetchChatHistory: () => Promise<void>;
|
||||||
|
onSearch: (keyword: string) => void;
|
||||||
|
onRename: (chat: any, title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||||
isSidebarOpen,
|
isSidebarOpen,
|
||||||
chats,
|
chats,
|
||||||
activeChat,
|
activeChat,
|
||||||
onNewChat,
|
// onNewChat,
|
||||||
onSelectChat,
|
onSelectChat,
|
||||||
onDeleteChat,
|
onDeleteChat,
|
||||||
fetchChatHistory,
|
fetchChatHistory,
|
||||||
|
onSearch,
|
||||||
|
onRename,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-sidebar
|
data-sidebar
|
||||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
|
className={`
|
||||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-[calc(100%)]"}
|
h-[calc(100%+90px)] absolute top-0 left-0 z-10 w-64
|
||||||
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
|
transform transition-all duration-300 ease-in-out
|
||||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||||
overflow-hidden`}
|
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
|
{isSidebarOpen && (
|
||||||
|
<HistoryList
|
||||||
|
id={HISTORY_PANEL_ID}
|
||||||
|
list={chats}
|
||||||
|
active={activeChat}
|
||||||
|
onSearch={onSearch}
|
||||||
|
onRefresh={fetchChatHistory}
|
||||||
|
onSelect={onSelectChat}
|
||||||
|
onRename={onRename}
|
||||||
|
onRemove={onDeleteChat}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* <Sidebar
|
||||||
chats={chats}
|
chats={chats}
|
||||||
activeChat={activeChat}
|
activeChat={activeChat}
|
||||||
onNewChat={onNewChat}
|
onNewChat={onNewChat}
|
||||||
onSelectChat={onSelectChat}
|
onSelectChat={onSelectChat}
|
||||||
onDeleteChat={onDeleteChat}
|
onDeleteChat={onDeleteChat}
|
||||||
fetchChatHistory={fetchChatHistory}
|
fetchChatHistory={fetchChatHistory}
|
||||||
/>
|
/> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
|
||||||
|
|
||||||
import LoginDark from "@/assets/images/login-dark.svg";
|
import LoginDark from "@/assets/images/login-dark.svg";
|
||||||
import LoginLight from "@/assets/images/login-light.svg";
|
import LoginLight from "@/assets/images/login-light.svg";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
|
import platformAdapter from "@/utils/platformAdapter";
|
||||||
|
|
||||||
const ConnectPrompt = () => {
|
const ConnectPrompt = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -13,7 +13,7 @@ const ConnectPrompt = () => {
|
|||||||
const logo = isDark ? LoginDark : LoginLight;
|
const logo = isDark ? LoginDark : LoginLight;
|
||||||
|
|
||||||
const handleConnect = async () => {
|
const handleConnect = async () => {
|
||||||
emit("open_settings", "connect");
|
platformAdapter.emitEvent("open_settings", "connect");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ChatMessage } from "@/components/ChatMessage";
|
import { ChatMessage } from "@/components/ChatMessage";
|
||||||
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
|
|
||||||
export const Greetings = () => {
|
export const Greetings = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
@@ -12,7 +14,9 @@ export const Greetings = () => {
|
|||||||
_id: "greetings",
|
_id: "greetings",
|
||||||
_source: {
|
_source: {
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
message: t("assistant.chat.greetings"),
|
message:
|
||||||
|
currentAssistant?._source?.chat_settings?.greeting_message ||
|
||||||
|
t("assistant.chat.greetings"),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
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;
|
||||||
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;
|
title?: string;
|
||||||
question?: string;
|
question?: string;
|
||||||
details?: any[];
|
details?: any[];
|
||||||
|
assistant_id?: string;
|
||||||
}
|
}
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
_id: string;
|
_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]);
|
}, [ChunkData?.message_chunk]);
|
||||||
|
|
||||||
// Must be after hooks !!!
|
// Must be after hooks !!!
|
||||||
if (!ChunkData && !Detail) return null;
|
if (!ChunkData && !Detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,7 +84,7 @@ export const DeepRead = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isThinkingExpanded && (
|
{isThinkingExpanded && (
|
||||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||||
<div className="mb-4 space-y-3 text-xs">
|
<div className="mb-4 space-y-3 text-xs">
|
||||||
{Data?.map((item) => (
|
{Data?.map((item) => (
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ interface ISourceData {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
export const FetchSource = ({
|
||||||
|
Detail,
|
||||||
|
ChunkData,
|
||||||
|
loading,
|
||||||
|
}: FetchSourceProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
|
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
|
||||||
|
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -51,30 +56,34 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ChunkData?.message_chunk) return;
|
if (!ChunkData?.message_chunk) return;
|
||||||
|
|
||||||
try {
|
if (!loading) {
|
||||||
const match = ChunkData.message_chunk.match(
|
try {
|
||||||
/\u003cPayload total=(\d+)\u003e/
|
const match = ChunkData.message_chunk.match(
|
||||||
);
|
// /\u003cPayload total=(\d+)\u003e/
|
||||||
if (match) {
|
/<Payload total=(\d+)>/
|
||||||
setTotal(Number(match[1]));
|
);
|
||||||
}
|
if (match) {
|
||||||
|
setTotal(Number(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
// const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
||||||
if (jsonMatch) {
|
const jsonMatch = ChunkData.message_chunk.match(/\[([\s\S]*)\]/);
|
||||||
const jsonData = JSON.parse(jsonMatch[0]);
|
if (jsonMatch) {
|
||||||
setData(jsonData);
|
const jsonData = JSON.parse(jsonMatch[0]);
|
||||||
|
setData(jsonData);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse fetch source data:", e);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse fetch source data:", e);
|
|
||||||
}
|
}
|
||||||
}, [ChunkData?.message_chunk]);
|
}, [ChunkData?.message_chunk, loading]);
|
||||||
|
|
||||||
// Must be after hooks !!!
|
// Must be after hooks !!!
|
||||||
if (!ChunkData && !Detail) return null;
|
if (!ChunkData && !Detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mt-2 mb-2 w-[610px] ${
|
className={`mt-2 mb-2 max-w-full w-full md:w-[610px] ${
|
||||||
isSourceExpanded
|
isSourceExpanded
|
||||||
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
|
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
|
||||||
: ""
|
: ""
|
||||||
@@ -120,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"
|
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-full flex items-center gap-2">
|
<div className="w-full flex items-center gap-2">
|
||||||
<div className="w-[75%] flex items-center gap-1">
|
<div className="w-full md:w-[75%] flex items-center gap-1">
|
||||||
<Globe className="w-3 h-3 flex-shrink-0" />
|
<Globe className="w-3 h-3 flex-shrink-0" />
|
||||||
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
|
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
|
||||||
{item.title || item.category}
|
{item.title || item.category}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[25%] flex items-center justify-end gap-2">
|
<div
|
||||||
|
className={`flex mobile:hidden w-[25%] items-center justify-end gap-2`}
|
||||||
|
>
|
||||||
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
|
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
|
||||||
{item.source?.name}
|
{item.source?.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import RehypeHighlight from "rehype-highlight";
|
|||||||
import mermaid from "mermaid";
|
import mermaid from "mermaid";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
|
||||||
import { copyToClipboard,
|
import {
|
||||||
// useWindowSize
|
copyToClipboard,
|
||||||
|
// useWindowSize
|
||||||
} from "@/utils";
|
} from "@/utils";
|
||||||
|
|
||||||
import "./markdown.css";
|
import "./markdown.scss";
|
||||||
import "./highlight.css";
|
import "./highlight.css";
|
||||||
|
|
||||||
// 8
|
// 8
|
||||||
@@ -296,18 +297,20 @@ export default function Markdown(
|
|||||||
const mdRef = useRef<HTMLDivElement>(null);
|
const mdRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="coco-chat">
|
||||||
className="markdown-body"
|
<div
|
||||||
style={{
|
className="markdown-body"
|
||||||
fontSize: `${props.fontSize ?? 14}px`,
|
style={{
|
||||||
fontFamily: props.fontFamily || "inherit",
|
fontSize: `${props.fontSize ?? 14}px`,
|
||||||
}}
|
fontFamily: props.fontFamily || "inherit",
|
||||||
ref={mdRef}
|
}}
|
||||||
onContextMenu={props.onContextMenu}
|
ref={mdRef}
|
||||||
onDoubleClickCapture={props.onDoubleClickCapture}
|
onContextMenu={props.onContextMenu}
|
||||||
dir="auto"
|
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||||
>
|
dir="auto"
|
||||||
<MarkdownContent content={props.content} />
|
>
|
||||||
|
<MarkdownContent content={props.content} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const PickSource = ({
|
|||||||
}
|
}
|
||||||
}, [ChunkData?.message_chunk, loading]);
|
}, [ChunkData?.message_chunk, loading]);
|
||||||
|
|
||||||
// Must be after hooks !!!
|
// Must be after hooks !!!
|
||||||
if (!ChunkData && !Detail) return null;
|
if (!ChunkData && !Detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,7 +103,7 @@ export const PickSource = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isThinkingExpanded && (
|
{isThinkingExpanded && (
|
||||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||||
<div className="mb-4 space-y-3 text-xs">
|
<div className="mb-4 space-y-3 text-xs">
|
||||||
{Data?.map((item) => (
|
{Data?.map((item) => (
|
||||||
|
|||||||
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;
|
||||||
@@ -66,7 +66,7 @@ export const QueryIntent = ({
|
|||||||
}
|
}
|
||||||
}, [ChunkData?.message_chunk]);
|
}, [ChunkData?.message_chunk]);
|
||||||
|
|
||||||
// Must be after hooks !!!
|
// Must be after hooks !!!
|
||||||
if (!ChunkData && !Detail) return null;
|
if (!ChunkData && !Detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,7 +97,7 @@ export const QueryIntent = ({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isThinkingExpanded && (
|
{isThinkingExpanded && (
|
||||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||||
<div className="mb-4 space-y-2 text-xs">
|
<div className="mb-4 space-y-2 text-xs">
|
||||||
{Data?.keyword ? (
|
{Data?.keyword ? (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
|||||||
setData(ChunkData?.message_chunk);
|
setData(ChunkData?.message_chunk);
|
||||||
}, [ChunkData?.message_chunk, Data]);
|
}, [ChunkData?.message_chunk, Data]);
|
||||||
|
|
||||||
// Must be after hooks !!!
|
// Must be after hooks !!!
|
||||||
if (!ChunkData && !Detail) return null;
|
if (!ChunkData && !Detail) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -57,7 +57,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
{isThinkingExpanded && (
|
{isThinkingExpanded && (
|
||||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||||
{Data?.split("\n").map(
|
{Data?.split("\n").map(
|
||||||
(paragraph, idx) =>
|
(paragraph, idx) =>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { memo, useState } from "react";
|
import { memo, useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
import logoImg from "@/assets/icon.svg";
|
import logoImg from "@/assets/icon.svg";
|
||||||
import type { Message, IChunkData } from "@/components/Assistant/types";
|
import type { Message, IChunkData } from "@/components/Assistant/types";
|
||||||
import { QueryIntent } from "./QueryIntent";
|
import { QueryIntent } from "./QueryIntent";
|
||||||
|
import { CallTools } from "./CallTools";
|
||||||
import { FetchSource } from "./FetchSource";
|
import { FetchSource } from "./FetchSource";
|
||||||
import { PickSource } from "./PickSource";
|
import { PickSource } from "./PickSource";
|
||||||
import { DeepRead } from "./DeepRead";
|
import { DeepRead } from "./DeepRead";
|
||||||
@@ -12,11 +14,14 @@ import { MessageActions } from "./MessageActions";
|
|||||||
import Markdown from "./Markdown";
|
import Markdown from "./Markdown";
|
||||||
import { SuggestionList } from "./SuggestionList";
|
import { SuggestionList } from "./SuggestionList";
|
||||||
import { UserMessage } from "./UserMessage";
|
import { UserMessage } from "./UserMessage";
|
||||||
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
|
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
isTyping?: boolean;
|
isTyping?: boolean;
|
||||||
query_intent?: IChunkData;
|
query_intent?: IChunkData;
|
||||||
|
tools?: IChunkData;
|
||||||
fetch_source?: IChunkData;
|
fetch_source?: IChunkData;
|
||||||
pick_source?: IChunkData;
|
pick_source?: IChunkData;
|
||||||
deep_read?: IChunkData;
|
deep_read?: IChunkData;
|
||||||
@@ -30,6 +35,7 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
message,
|
message,
|
||||||
isTyping,
|
isTyping,
|
||||||
query_intent,
|
query_intent,
|
||||||
|
tools,
|
||||||
fetch_source,
|
fetch_source,
|
||||||
pick_source,
|
pick_source,
|
||||||
deep_read,
|
deep_read,
|
||||||
@@ -40,7 +46,24 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
}: ChatMessageProps) {
|
}: ChatMessageProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||||
|
const assistantList = useConnectStore((state) => state.assistantList);
|
||||||
|
const [assistant, setAssistant] = useState<any>({});
|
||||||
|
|
||||||
const isAssistant = message?._source?.type === "assistant";
|
const isAssistant = message?._source?.type === "assistant";
|
||||||
|
const assistant_id = message?._source?.assistant_id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let target = currentAssistant;
|
||||||
|
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
|
||||||
|
const found = assistantList.find((item) => item._id === assistant_id);
|
||||||
|
if (found) {
|
||||||
|
target = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAssistant(target);
|
||||||
|
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
|
||||||
|
|
||||||
const messageContent = message?._source?.message || "";
|
const messageContent = message?._source?.message || "";
|
||||||
const details = message?._source?.details || [];
|
const details = message?._source?.details || [];
|
||||||
const question = message?._source?.question || "";
|
const question = message?._source?.question || "";
|
||||||
@@ -49,6 +72,7 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
isTyping === false && (messageContent || response?.message_chunk);
|
isTyping === false && (messageContent || response?.message_chunk);
|
||||||
|
|
||||||
const [suggestion, setSuggestion] = useState<string[]>([]);
|
const [suggestion, setSuggestion] = useState<string[]>([]);
|
||||||
|
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||||
|
|
||||||
const getSuggestion = (suggestion: string[]) => {
|
const getSuggestion = (suggestion: string[]) => {
|
||||||
setSuggestion(suggestion);
|
setSuggestion(suggestion);
|
||||||
@@ -67,6 +91,13 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
getSuggestion={getSuggestion}
|
getSuggestion={getSuggestion}
|
||||||
loading={loadingStep?.query_intent}
|
loading={loadingStep?.query_intent}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CallTools
|
||||||
|
Detail={details.find((item) => item.type === "tools")}
|
||||||
|
ChunkData={tools}
|
||||||
|
loading={loadingStep?.tools}
|
||||||
|
/>
|
||||||
|
|
||||||
<FetchSource
|
<FetchSource
|
||||||
Detail={details.find((item) => item.type === "fetch_source")}
|
Detail={details.find((item) => item.type === "fetch_source")}
|
||||||
ChunkData={fetch_source}
|
ChunkData={fetch_source}
|
||||||
@@ -117,7 +148,13 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
|
className={clsx(
|
||||||
|
"py-8 flex",
|
||||||
|
[isAssistant ? "justify-start" : "justify-end"],
|
||||||
|
{
|
||||||
|
hidden: visibleStartPage,
|
||||||
|
}
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`px-4 flex gap-4 ${
|
className={`px-4 flex gap-4 ${
|
||||||
@@ -125,20 +162,28 @@ export const ChatMessage = memo(function ChatMessage({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`space-y-2 ${isAssistant ? "text-left" : "text-right"}`}
|
className={`w-full space-y-2 ${
|
||||||
|
isAssistant ? "text-left" : "text-right"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<p className="flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||||
{isAssistant ? (
|
{isAssistant ? (
|
||||||
<img
|
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||||
src={logoImg}
|
{assistant?._source?.icon?.startsWith("font_") ? (
|
||||||
className="w-6 h-6"
|
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
|
||||||
alt={t("assistant.message.logo")}
|
) : (
|
||||||
/>
|
<img
|
||||||
|
src={logoImg}
|
||||||
|
className="w-4 h-4"
|
||||||
|
alt={t("assistant.message.logo")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isAssistant ? t("assistant.message.aiName") : ""}
|
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
|
||||||
</p>
|
</div>
|
||||||
<div className="prose dark:prose-invert prose-sm max-w-none">
|
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
|
||||||
<div className="pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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,
|
getCurrent as getCurrentDeepLinkUrls,
|
||||||
onOpenUrl,
|
onOpenUrl,
|
||||||
} from "@tauri-apps/plugin-deep-link";
|
} from "@tauri-apps/plugin-deep-link";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { emit } from "@tauri-apps/api/event";
|
||||||
@@ -29,14 +28,24 @@ import { useConnectStore } from "@/stores/connectStore";
|
|||||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||||
import Tooltip from "@/components/Common/Tooltip";
|
import Tooltip from "@/components/Common/Tooltip";
|
||||||
|
import {
|
||||||
|
list_coco_servers,
|
||||||
|
add_coco_server,
|
||||||
|
enable_server,
|
||||||
|
disable_server,
|
||||||
|
logout_coco_server,
|
||||||
|
remove_coco_server,
|
||||||
|
refresh_coco_server_info,
|
||||||
|
handle_sso_callback,
|
||||||
|
} from "@/commands";
|
||||||
|
|
||||||
export default function Cloud() {
|
export default function Cloud() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||||
|
|
||||||
const error = useAppStore((state) => state.error);
|
const errors = useAppStore((state) => state.errors);
|
||||||
const setError = useAppStore((state) => state.setError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
|
|
||||||
const [isConnect, setIsConnect] = useState(true);
|
const [isConnect, setIsConnect] = useState(true);
|
||||||
|
|
||||||
@@ -61,14 +70,13 @@ export default function Cloud() {
|
|||||||
console.log("currentService", currentService);
|
console.log("currentService", currentService);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
setError("");
|
|
||||||
setIsConnect(true);
|
setIsConnect(true);
|
||||||
}, [JSON.stringify(currentService)]);
|
}, [JSON.stringify(currentService)]);
|
||||||
|
|
||||||
const fetchServers = async (resetSelection: boolean) => {
|
const fetchServers = async (resetSelection: boolean) => {
|
||||||
invoke("list_coco_servers")
|
list_coco_servers()
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
if (error) {
|
if (errors.length > 0) {
|
||||||
res = (res || []).map((item: any) => {
|
res = (res || []).map((item: any) => {
|
||||||
if (item.id === currentService?.id) {
|
if (item.id === currentService?.id) {
|
||||||
item.health = {
|
item.health = {
|
||||||
@@ -93,12 +101,11 @@ export default function Cloud() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
setError(err);
|
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const add_coco_server = (endpointLink: string) => {
|
const addServer = (endpointLink: string) => {
|
||||||
if (!endpointLink) {
|
if (!endpointLink) {
|
||||||
throw new Error("Endpoint is required");
|
throw new Error("Endpoint is required");
|
||||||
}
|
}
|
||||||
@@ -111,25 +118,13 @@ export default function Cloud() {
|
|||||||
|
|
||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
|
|
||||||
return invoke("add_coco_server", { endpoint: endpointLink })
|
return add_coco_server(endpointLink)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// console.log("add_coco_server", res);
|
// console.log("add_coco_server", res);
|
||||||
fetchServers(false)
|
fetchServers(false).then((r) => {
|
||||||
.then((r) => {
|
console.log("fetchServers", r);
|
||||||
console.log("fetchServers", r);
|
setCurrentService(res);
|
||||||
setCurrentService(res);
|
});
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
console.error("fetchServers failed:", err);
|
|
||||||
setError(err);
|
|
||||||
throw err; // Propagate error back up to outer promise chain
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
// Handle the invoke error
|
|
||||||
console.error("add coco server failed:", err);
|
|
||||||
setError(err);
|
|
||||||
throw err; // Propagate error back up
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
@@ -138,14 +133,14 @@ export default function Cloud() {
|
|||||||
|
|
||||||
const handleOAuthCallback = useCallback(
|
const handleOAuthCallback = useCallback(
|
||||||
async (code: string | null, serverId: string | null) => {
|
async (code: string | null, serverId: string | null) => {
|
||||||
if (!code) {
|
if (!code || !serverId) {
|
||||||
setError("No authorization code received");
|
addError("No authorization code received");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Handling OAuth callback:", { code, serverId });
|
console.log("Handling OAuth callback:", { code, serverId });
|
||||||
await invoke("handle_sso_callback", {
|
await handle_sso_callback({
|
||||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||||
code: code,
|
code: code,
|
||||||
@@ -155,15 +150,9 @@ export default function Cloud() {
|
|||||||
refreshClick(serverId);
|
refreshClick(serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getCurrentWindow()
|
getCurrentWindow().setFocus();
|
||||||
.setFocus()
|
|
||||||
.catch((err) => {
|
|
||||||
setError(err);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Sign in failed:", e);
|
console.error("Sign in failed:", e);
|
||||||
setError("SSO login failed: " + e);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -182,7 +171,7 @@ export default function Cloud() {
|
|||||||
|
|
||||||
if (reqId != ssoRequestID) {
|
if (reqId != ssoRequestID) {
|
||||||
console.log("Request ID not matched, skip");
|
console.log("Request ID not matched, skip");
|
||||||
setError("Request ID not matched, skip");
|
addError("Request ID not matched, skip");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +179,7 @@ export default function Cloud() {
|
|||||||
handleOAuthCallback(code, serverId);
|
handleOAuthCallback(code, serverId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to parse URL:", err);
|
console.error("Failed to parse URL:", err);
|
||||||
setError("Invalid URL format: " + err);
|
addError("Invalid URL format: " + err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,7 +215,7 @@ export default function Cloud() {
|
|||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("Failed to get initial URLs:", err);
|
console.error("Failed to get initial URLs:", err);
|
||||||
setError("Failed to get initial URLs: " + err);
|
addError("Failed to get initial URLs: " + err);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||||
@@ -257,7 +246,7 @@ export default function Cloud() {
|
|||||||
|
|
||||||
const refreshClick = (id: string) => {
|
const refreshClick = (id: string) => {
|
||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
invoke("refresh_coco_server_info", { id })
|
refresh_coco_server_info(id)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
console.log("refresh_coco_server_info", id, res);
|
console.log("refresh_coco_server_info", id, res);
|
||||||
fetchServers(false).then((r) => {
|
fetchServers(false).then((r) => {
|
||||||
@@ -267,10 +256,6 @@ export default function Cloud() {
|
|||||||
setCurrentService(res);
|
setCurrentService(res);
|
||||||
emit("login_or_logout", true);
|
emit("login_or_logout", true);
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
setError(err);
|
|
||||||
console.error(err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
});
|
});
|
||||||
@@ -283,49 +268,37 @@ export default function Cloud() {
|
|||||||
function onLogout(id: string) {
|
function onLogout(id: string) {
|
||||||
console.log("onLogout", id);
|
console.log("onLogout", id);
|
||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
invoke("logout_coco_server", { id })
|
logout_coco_server(id)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||||
refreshClick(id);
|
refreshClick(id);
|
||||||
emit("login_or_logout", false);
|
emit("login_or_logout", false);
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
setError(err);
|
|
||||||
console.error(err);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove_coco_server = (id: string) => {
|
const removeServer = (id: string) => {
|
||||||
invoke("remove_coco_server", { id })
|
remove_coco_server(id).then((res: any) => {
|
||||||
.then((res: any) => {
|
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
fetchServers(true).then((r) => {
|
||||||
fetchServers(true).then((r) => {
|
console.log("fetchServers", r);
|
||||||
console.log("fetchServers", r);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err: any) => {
|
|
||||||
// TODO display the error message
|
|
||||||
setError(err);
|
|
||||||
console.error(err);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const enable_coco_server = useCallback(
|
const enable_coco_server = useCallback(
|
||||||
async (enabled: boolean) => {
|
async (enabled: boolean) => {
|
||||||
try {
|
if (enabled) {
|
||||||
const command = enabled ? "enable_server" : "disable_server";
|
await enable_server(currentService?.id);
|
||||||
|
} else {
|
||||||
await invoke(command, { id: currentService?.id });
|
await disable_server(currentService?.id);
|
||||||
|
|
||||||
setCurrentService({ ...currentService, enabled });
|
|
||||||
|
|
||||||
await fetchServers(false);
|
|
||||||
} catch (error) {
|
|
||||||
setError(error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCurrentService({ ...currentService, enabled });
|
||||||
|
|
||||||
|
await fetchServers(false);
|
||||||
},
|
},
|
||||||
[currentService?.id]
|
[currentService?.id]
|
||||||
);
|
);
|
||||||
@@ -391,7 +364,7 @@ export default function Cloud() {
|
|||||||
{!currentService?.builtin && (
|
{!currentService?.builtin && (
|
||||||
<button
|
<button
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||||
onClick={() => remove_coco_server(currentService?.id)}
|
onClick={() => removeServer(currentService?.id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||||
</button>
|
</button>
|
||||||
@@ -463,8 +436,7 @@ export default function Cloud() {
|
|||||||
<Copy className="inline mr-2" />{" "}
|
<Copy className="inline mr-2" />{" "}
|
||||||
</button>
|
</button>
|
||||||
<div className="text-justify italic text-xs">
|
<div className="text-justify italic text-xs">
|
||||||
If the link did not open automatically, please copy
|
{t("cloud.manualCopyLink")}
|
||||||
and paste it into your browser manually.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -490,7 +462,7 @@ export default function Cloud() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
|
<Connect setIsConnect={setIsConnect} onAddServer={addServer} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,9 +13,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [endpointLink, setEndpointLink] = useState("");
|
const [endpointLink, setEndpointLink] = useState("");
|
||||||
const [refreshLoading] = useState(false);
|
const [refreshLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState(""); // State to store the error message
|
|
||||||
|
|
||||||
const setError = useAppStore((state) => state.setError);
|
const addError = useAppStore((state) => state.addError);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -36,17 +35,10 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
|||||||
typeof err === "string"
|
typeof err === "string"
|
||||||
? err
|
? err
|
||||||
: err?.message || "An unknown error occurred.";
|
: err?.message || "An unknown error occurred.";
|
||||||
setErrorMessage("ERR:" + errorMessage);
|
addError(errorMessage);
|
||||||
setError(errorMessage);
|
|
||||||
console.error("Error:", errorMessage);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to close the error message
|
|
||||||
const closeError = () => {
|
|
||||||
setErrorMessage("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl">
|
<div className="max-w-4xl">
|
||||||
<div className="flex items-center gap-2 mb-8">
|
<div className="flex items-center gap-2 mb-8">
|
||||||
@@ -96,31 +88,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/*//TODO move to outer container, move error state to global*/}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="mb-8">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
color: "red",
|
|
||||||
marginTop: "10px",
|
|
||||||
display: "block", // Makes sure the error message starts on a new line
|
|
||||||
marginBottom: "10px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
<button
|
|
||||||
onClick={closeError}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "red",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import source_default_img from "@/assets/images/source_default.png";
|
|||||||
import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useThemeStore } from "@/stores/themeStore";
|
import { useThemeStore } from "@/stores/themeStore";
|
||||||
|
import FontIcon from "../Common/Icons/FontIcon";
|
||||||
|
|
||||||
interface Account {
|
interface Account {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -13,11 +14,12 @@ interface Account {
|
|||||||
|
|
||||||
interface DataSourceItemProps {
|
interface DataSourceItemProps {
|
||||||
name: string;
|
name: string;
|
||||||
|
icon?: string;
|
||||||
connector: any;
|
connector: any;
|
||||||
accounts?: Account[];
|
accounts?: Account[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
||||||
// const isConnected = true;
|
// const isConnected = true;
|
||||||
|
|
||||||
const isDark = useThemeStore((state) => state.isDark);
|
const isDark = useThemeStore((state) => state.isDark);
|
||||||
@@ -56,7 +58,12 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
|||||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
|
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<img src={getTypeIcon()} alt={name} className="w-6 h-6" />
|
{icon?.startsWith("font_") ? (
|
||||||
|
<FontIcon name={icon} className="size-6" />
|
||||||
|
) : (
|
||||||
|
<img src={getTypeIcon()} alt={name} className="size-6" />
|
||||||
|
)}
|
||||||
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,43 +1,37 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RefreshCcw } from "lucide-react";
|
import { RefreshCcw } from "lucide-react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
|
|
||||||
import { DataSourceItem } from "./DataSourceItem";
|
import { DataSourceItem } from "./DataSourceItem";
|
||||||
import { useConnectStore } from "@/stores/connectStore";
|
import { useConnectStore } from "@/stores/connectStore";
|
||||||
import { useAppStore } from "@/stores/appStore";
|
import {
|
||||||
|
get_connectors_by_server,
|
||||||
|
datasource_search,
|
||||||
|
} from "@/commands";
|
||||||
|
|
||||||
export function DataSourcesList({ server }: { server: string }) {
|
export function DataSourcesList({ server }: { server: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||||
const setError = useAppStore((state) => state.setError);
|
|
||||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||||
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
||||||
|
|
||||||
function initServerAppData({ server }: { server: string }) {
|
function initServerAppData({ server }: { server: string }) {
|
||||||
//fetch datasource data
|
//fetch datasource data
|
||||||
invoke("get_connectors_by_server", { id: server })
|
get_connectors_by_server(server)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// console.log("get_connectors_by_server", res);
|
// console.log("get_connectors_by_server", res);
|
||||||
setConnectorData(res, server);
|
setConnectorData(res, server);
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
setError(err);
|
|
||||||
throw err; // Propagate error back up
|
|
||||||
})
|
|
||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
|
|
||||||
//fetch datasource data
|
//fetch datasource data
|
||||||
invoke("get_datasources_by_server", { id: server })
|
datasource_search(server)
|
||||||
.then((res: any) => {
|
.then((res: any) => {
|
||||||
// console.log("get_datasources_by_server", res);
|
// console.log("datasource_search", res);
|
||||||
setDatasourceData(res, server);
|
setDatasourceData(res, server);
|
||||||
})
|
})
|
||||||
.catch((err: any) => {
|
|
||||||
setError(err);
|
|
||||||
throw err; // Propagate error back up
|
|
||||||
})
|
|
||||||
.finally(() => {});
|
.finally(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +39,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
|||||||
setRefreshLoading(true);
|
setRefreshLoading(true);
|
||||||
try {
|
try {
|
||||||
initServerAppData({ server });
|
initServerAppData({ server });
|
||||||
} catch (e) {
|
|
||||||
setError(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshLoading(false);
|
setRefreshLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item?.id}
|
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
|
currentService?.id === item?.id
|
||||||
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
|
? "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"
|
: "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
|
<img
|
||||||
src={item?.provider?.icon || cocoLogoImg}
|
src={item?.provider?.icon || cocoLogoImg}
|
||||||
alt="LogoImg"
|
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" />
|
<div className="flex-1" />
|
||||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||||
{item.health?.status ? (
|
{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 React, { useEffect, useCallback } from "react";
|
||||||
import { Bot, Search } from "lucide-react";
|
import { Bot, Search } from "lucide-react";
|
||||||
|
|
||||||
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||||
|
|
||||||
interface ChatSwitchProps {
|
interface ChatSwitchProps {
|
||||||
isChatMode: boolean;
|
isChatMode: boolean;
|
||||||
@@ -9,19 +9,26 @@ interface ChatSwitchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
||||||
|
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||||
|
return state.modifierKeyPressed;
|
||||||
|
});
|
||||||
|
const modeSwitch = useShortcutsStore((state) => {
|
||||||
|
return state.modeSwitch;
|
||||||
|
});
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
onChange?.(!isChatMode);
|
onChange?.(!isChatMode);
|
||||||
}, [onChange, isChatMode]);
|
}, [onChange, isChatMode]);
|
||||||
|
|
||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
(event: KeyboardEvent) => {
|
(event: KeyboardEvent) => {
|
||||||
if (isMetaOrCtrlKey(event) && event.key === "t") {
|
if (modifierKeyPressed && event.key === modeSwitch.toLowerCase()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
// console.log("Switch mode triggered");
|
// console.log("Switch mode triggered");
|
||||||
handleToggle();
|
handleToggle();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleToggle]
|
[handleToggle, modifierKeyPressed, modeSwitch]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
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;
|
||||||
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;
|
||||||
32
src/components/Common/ErrorDisplay.tsx
Normal file
32
src/components/Common/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import errorImg from "@/assets/error_page.png";
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ errorMessage }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-screen bg-white shadow-[0px_16px_32px_0px_rgba(0,0,0,0.4)] rounded-xl border-[2px] border-[#E6E6E6] m-auto">
|
||||||
|
<div className="flex flex-col justify-center items-center">
|
||||||
|
<img
|
||||||
|
src={errorImg}
|
||||||
|
alt="error-page"
|
||||||
|
className="w-[221px] h-[154px] mb-8 mt-[72px]"
|
||||||
|
/>
|
||||||
|
<div className="w-[380px] h-[46px] px-5 font-normal text-base text-[rgba(0,0,0,0.85)] leading-[25px] text-center mb-4">
|
||||||
|
{t('error.message')}
|
||||||
|
</div>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="w-[380px] h-[45px] font-normal text-[10px] text-[rgba(135,135,135,0.85)] leading-[16px] text-center">
|
||||||
|
<i>{errorMessage}</i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
74
src/components/Common/ErrorNotification/index.tsx
Normal file
74
src/components/Common/ErrorNotification/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { X, AlertCircle, AlertTriangle, Info } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAppStore } from "@/stores/appStore";
|
||||||
|
|
||||||
|
interface ErrorNotificationProps {
|
||||||
|
duration?: number;
|
||||||
|
autoClose?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ErrorNotification = ({
|
||||||
|
duration = 3000,
|
||||||
|
autoClose = true
|
||||||
|
}: ErrorNotificationProps) => {
|
||||||
|
const errors = useAppStore((state) => state.errors);
|
||||||
|
const removeError = useAppStore((state) => state.removeError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoClose) return;
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
errors.forEach((error) => {
|
||||||
|
if (now - error.timestamp > duration) {
|
||||||
|
removeError(error.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [errors, duration, autoClose]);
|
||||||
|
|
||||||
|
if (errors.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-10 right-4 z-50 max-w-[calc(100%-32px)] space-y-2">
|
||||||
|
{errors.map((error) => (
|
||||||
|
<div
|
||||||
|
key={error.id}
|
||||||
|
className={`flex justify-between gap-4 items-center p-4 rounded-lg shadow-lg ${
|
||||||
|
error.type === "error"
|
||||||
|
? "bg-red-50 dark:bg-red-900"
|
||||||
|
: error.type === "warning"
|
||||||
|
? "bg-yellow-50 dark:bg-yellow-900"
|
||||||
|
: "bg-blue-50 dark:bg-blue-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{error.type === "error" && (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
|
||||||
|
)}
|
||||||
|
{error.type === "warning" && (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
|
||||||
|
)}
|
||||||
|
{error.type === "info" && (
|
||||||
|
<Info className="w-5 h-5 text-blue-500 mr-2" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-200">
|
||||||
|
{error.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<X
|
||||||
|
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
|
||||||
|
onClick={() => removeError(error.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ErrorNotification;
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import {
|
|
||||||
// Settings,
|
|
||||||
// LogOut,
|
|
||||||
Command,
|
|
||||||
// User,
|
|
||||||
// Home,
|
|
||||||
// ChevronUp,
|
|
||||||
ArrowDown01,
|
|
||||||
AppWindowMac,
|
|
||||||
// ArrowDownUp,
|
|
||||||
CornerDownLeft,
|
|
||||||
} from "lucide-react";
|
|
||||||
// import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
|
|
||||||
// import { Link } from "react-router-dom";
|
|
||||||
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
|
|
||||||
interface FooterProps {
|
|
||||||
isChat: boolean;
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Footer = ({ name }: FooterProps) => {
|
|
||||||
// async function openWebviewWindowSettings() {
|
|
||||||
// const webview = new WebviewWindow("settings", {
|
|
||||||
// title: "Coco Settings",
|
|
||||||
// dragDropEnabled: true,
|
|
||||||
// center: true,
|
|
||||||
// width: 900,
|
|
||||||
// height: 700,
|
|
||||||
// alwaysOnTop: true,
|
|
||||||
// skipTaskbar: true,
|
|
||||||
// decorations: true,
|
|
||||||
// closable: true,
|
|
||||||
// url: "/ui/settings",
|
|
||||||
// });
|
|
||||||
// webview.once("tauri://created", function () {
|
|
||||||
// console.log("webview created");
|
|
||||||
// });
|
|
||||||
// webview.once("tauri://error", function (e) {
|
|
||||||
// console.log("error creating webview", e);
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-tauri-drag-region
|
|
||||||
className="px-4 z-999 mx-[1px] h-10 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{
|
|
||||||
name ? (
|
|
||||||
<div className="flex gap-2 items-center text-[#666] text-xs">
|
|
||||||
<AppWindowMac className="w-5 h-5" /> {name}
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
// <Menu as="div" className="relative">
|
|
||||||
// <MenuButton className="h-7 flex items-center space-x-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
|
||||||
// <Command className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
|
||||||
// <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
||||||
// Coco
|
|
||||||
// </span>
|
|
||||||
// <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
|
||||||
// </MenuButton>
|
|
||||||
|
|
||||||
// <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
|
||||||
// <div className="p-1">
|
|
||||||
// <MenuItem>
|
|
||||||
// {({ active }) => (
|
|
||||||
// <button
|
|
||||||
// className={`${
|
|
||||||
// active
|
|
||||||
// ? "bg-gray-100 dark:bg-gray-700"
|
|
||||||
// : "text-gray-900 dark:text-gray-100"
|
|
||||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
|
||||||
// >
|
|
||||||
// <Home className="w-4 h-4 mr-2" />
|
|
||||||
// <Link to={`/`}>Home</Link>
|
|
||||||
// </button>
|
|
||||||
// )}
|
|
||||||
// </MenuItem>
|
|
||||||
// {/* <MenuItem>
|
|
||||||
// {({ active }) => (
|
|
||||||
// <button
|
|
||||||
// className={`${
|
|
||||||
// active
|
|
||||||
// ? "bg-gray-100 dark:bg-gray-700"
|
|
||||||
// : "text-gray-900 dark:text-gray-100"
|
|
||||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
|
||||||
// >
|
|
||||||
// <User className="w-4 h-4 mr-2" />
|
|
||||||
// Profile
|
|
||||||
// </button>
|
|
||||||
// )}
|
|
||||||
// </MenuItem> */}
|
|
||||||
// <MenuItem>
|
|
||||||
// {({ active }) => (
|
|
||||||
// <button
|
|
||||||
// className={`${
|
|
||||||
// active
|
|
||||||
// ? "bg-gray-100 dark:bg-gray-700"
|
|
||||||
// : "text-gray-900 dark:text-gray-100"
|
|
||||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
|
||||||
// onClick={openWebviewWindowSettings}
|
|
||||||
// >
|
|
||||||
// <Settings className="w-4 h-4 mr-2" />
|
|
||||||
// Settings
|
|
||||||
// </button>
|
|
||||||
// )}
|
|
||||||
// </MenuItem>
|
|
||||||
// {/* <div className="h-px bg-gray-200 dark:bg-gray-700 my-1" />
|
|
||||||
// <MenuItem>
|
|
||||||
// {({ active }) => (
|
|
||||||
// <button
|
|
||||||
// className={`${
|
|
||||||
// active
|
|
||||||
// ? "bg-gray-100 dark:bg-gray-700"
|
|
||||||
// : "text-gray-900 dark:text-gray-100"
|
|
||||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
|
||||||
// >
|
|
||||||
// <LogOut className="w-4 h-4 mr-2" />
|
|
||||||
// Sign Out
|
|
||||||
// </button>
|
|
||||||
// )}
|
|
||||||
// </MenuItem> */}
|
|
||||||
// </div>
|
|
||||||
// </MenuItems>
|
|
||||||
// </Menu>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-sm">
|
|
||||||
<span className="mr-1.5 ">Quick open</span>
|
|
||||||
<Command className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
|
||||||
<ArrowDown01 className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center text-[#666] dark:text-[#666] text-sm">
|
|
||||||
<span className="mr-1.5 ">Open</span>
|
|
||||||
<CornerDownLeft className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
401
src/components/Common/HistoryList/index.tsx
Normal file
401
src/components/Common/HistoryList/index.tsx
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
import { useKeyPress } from "ahooks";
|
||||||
|
import {
|
||||||
|
Description,
|
||||||
|
Dialog,
|
||||||
|
DialogPanel,
|
||||||
|
DialogTitle,
|
||||||
|
Input,
|
||||||
|
Popover,
|
||||||
|
PopoverButton,
|
||||||
|
PopoverPanel,
|
||||||
|
} from "@headlessui/react";
|
||||||
|
import { debounce, groupBy, isNil } from "lodash-es";
|
||||||
|
import { FC, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import isSameOrAfter from "dayjs/plugin/isSameOrAfter";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import { Ellipsis, Pencil, RefreshCcw, Search, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import VisibleKey from "../VisibleKey";
|
||||||
|
import { Chat } from "@/components/Assistant/types";
|
||||||
|
import NoDataImage from "../NoDataImage";
|
||||||
|
|
||||||
|
dayjs.extend(isSameOrAfter);
|
||||||
|
|
||||||
|
interface HistoryListProps {
|
||||||
|
id?: string;
|
||||||
|
list: Chat[];
|
||||||
|
active?: Chat;
|
||||||
|
onSearch: (keyword: string) => void;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
|
onSelect: (chat: Chat) => void;
|
||||||
|
onRename: (chatId: string, title: string) => void;
|
||||||
|
onRemove: (chatId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HistoryList: FC<HistoryListProps> = (props) => {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
list,
|
||||||
|
active,
|
||||||
|
onSearch,
|
||||||
|
onRefresh,
|
||||||
|
onSelect,
|
||||||
|
onRename,
|
||||||
|
onRemove,
|
||||||
|
} = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const moreButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [isRefresh, setIsRefresh] = useState(false);
|
||||||
|
|
||||||
|
const sortedList = useMemo(() => {
|
||||||
|
if (isNil(list)) return {};
|
||||||
|
|
||||||
|
const now = dayjs();
|
||||||
|
|
||||||
|
return groupBy(list, (chat) => {
|
||||||
|
const date = dayjs(chat._source?.updated);
|
||||||
|
|
||||||
|
if (date.isSame(now, "day")) {
|
||||||
|
return "history_list.date.today";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.isSame(now.subtract(1, "day"), "day")) {
|
||||||
|
return "history_list.date.yesterday";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.isSameOrAfter(now.subtract(7, "day"), "day")) {
|
||||||
|
return "history_list.date.last7Days";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (date.isSameOrAfter(now.subtract(30, "day"), "day")) {
|
||||||
|
return "history_list.date.last30Days";
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.format("YYYY-MM");
|
||||||
|
});
|
||||||
|
}, [list]);
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
// {
|
||||||
|
// label: "history_list.menu.share",
|
||||||
|
// icon: Share2,
|
||||||
|
// onClick: () => {},
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
label: "history_list.menu.rename",
|
||||||
|
icon: Pencil,
|
||||||
|
shortcut: "R",
|
||||||
|
onClick: () => {
|
||||||
|
setIsEdit(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "history_list.menu.delete",
|
||||||
|
icon: Trash2,
|
||||||
|
shortcut: "D",
|
||||||
|
iconColor: "#FF2018",
|
||||||
|
onClick: () => {
|
||||||
|
setIsOpen(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const debouncedSearch = useMemo(() => {
|
||||||
|
return debounce((value: string) => onSearch(value), 300);
|
||||||
|
}, [onSearch]);
|
||||||
|
|
||||||
|
useKeyPress(["uparrow", "downarrow"], (_, key) => {
|
||||||
|
const index = list.findIndex((item) => item._id === active?._id);
|
||||||
|
const length = list.length;
|
||||||
|
|
||||||
|
let nextIndex = index;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "uparrow":
|
||||||
|
nextIndex = index === 0 ? length - 1 : index - 1;
|
||||||
|
break;
|
||||||
|
case "downarrow":
|
||||||
|
nextIndex = index === length - 1 ? 0 : index + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(list[nextIndex]);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active?._id || !listRef.current) return;
|
||||||
|
|
||||||
|
const activeEl = listRef.current.querySelector(`#${active._id}`);
|
||||||
|
|
||||||
|
activeEl?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, [active?._id]);
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
if (!active?._id) return;
|
||||||
|
|
||||||
|
onRemove(active._id);
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefresh(true);
|
||||||
|
|
||||||
|
await onRefresh();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsRefresh(false);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
id={id}
|
||||||
|
className={clsx(
|
||||||
|
"flex flex-col h-full overflow-auto px-3 py-2 text-sm bg-[#F3F4F6] dark:bg-[#1F2937] custom-scrollbar"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-1 children:h-8">
|
||||||
|
<div className="flex-1 flex items-center gap-2 px-2 rounded-lg border transition border-[#E6E6E6] bg-[#F8F9FA] dark:bg-[#2B3444] dark:border-[#343D4D] focus-within:border-[#0061FF]">
|
||||||
|
<VisibleKey
|
||||||
|
shortcut="F"
|
||||||
|
onKeyPress={() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search className="size-4 text-[#6B7280]" />
|
||||||
|
</VisibleKey>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
ref={searchInputRef}
|
||||||
|
className="w-full bg-transparent outline-none"
|
||||||
|
placeholder={t("history_list.search.placeholder")}
|
||||||
|
onChange={(event) => {
|
||||||
|
debouncedSearch(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="size-8 flex items-center justify-center rounded-lg border text-[#0072FF] border-[#E6E6E6] bg-[#F3F4F6] dark:border-[#343D4D] dark:bg-[#1F2937] hover:bg-[#F8F9FA] dark:hover:bg-[#353F4D] cursor-pointer transition"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
>
|
||||||
|
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||||
|
<RefreshCcw
|
||||||
|
className={clsx("size-4", {
|
||||||
|
"animate-spin": isRefresh,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</VisibleKey>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{list.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mt-6">
|
||||||
|
{Object.entries(sortedList).map(([label, list]) => {
|
||||||
|
return (
|
||||||
|
<div key={label}>
|
||||||
|
<span className="text-xs text-[#999] px-3">{t(label)}</span>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{list.map((item) => {
|
||||||
|
const { _id, _source } = item;
|
||||||
|
|
||||||
|
const isActive = _id === active?._id;
|
||||||
|
const title = _source?.title ?? _id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={_id}
|
||||||
|
id={_id}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center mt-1 h-10 rounded-lg cursor-pointer hover:bg-[#EDEDED] dark:hover:bg-[#353F4D] transition",
|
||||||
|
{
|
||||||
|
"!bg-[#E5E7EB] dark:!bg-[#2B3444]": isActive,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isActive) {
|
||||||
|
setIsEdit(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(item);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx("w-1 h-6 rounded-sm bg-[#0072FF]", {
|
||||||
|
"opacity-0": _id !== active?._id,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-between gap-2 px-2 overflow-hidden">
|
||||||
|
{isEdit && isActive ? (
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
defaultValue={title}
|
||||||
|
className="flex-1 -mx-px outline-none bg-transparent border border-[#0061FF] rounded-[4px]"
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Enter") return;
|
||||||
|
|
||||||
|
onRename(item._id, event.currentTarget.value);
|
||||||
|
|
||||||
|
setIsEdit(false);
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
onRename(item._id, event.target.value);
|
||||||
|
|
||||||
|
setIsEdit(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isActive && !isEdit && (
|
||||||
|
<VisibleKey
|
||||||
|
shortcut="↑↓"
|
||||||
|
rootClassName="w-6"
|
||||||
|
shortcutClassName="w-6"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover>
|
||||||
|
{isActive && !isEdit && (
|
||||||
|
<PopoverButton
|
||||||
|
ref={moreButtonRef}
|
||||||
|
className="flex gap-2"
|
||||||
|
>
|
||||||
|
<VisibleKey
|
||||||
|
shortcut="O"
|
||||||
|
onKeyPress={() => {
|
||||||
|
moreButtonRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ellipsis className="size-4 text-[#979797]" />
|
||||||
|
</VisibleKey>
|
||||||
|
</PopoverButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PopoverPanel
|
||||||
|
anchor="bottom"
|
||||||
|
className="flex flex-col rounded-lg shadow-md z-100 bg-white dark:bg-[#202126] p-1 border border-black/2 dark:border-white/10"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menuItems.map((menuItem) => {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
shortcut,
|
||||||
|
iconColor,
|
||||||
|
onClick,
|
||||||
|
} = menuItem;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm rounded-md hover:bg-[#EDEDED] dark:hover:bg-[#2B2C31] transition"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<VisibleKey
|
||||||
|
shortcut={shortcut}
|
||||||
|
onKeyPress={onClick}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="size-4"
|
||||||
|
style={{
|
||||||
|
color: iconColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</VisibleKey>
|
||||||
|
|
||||||
|
<span>{t(label)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PopoverPanel>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
className="relative z-1000"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="headlessui-popover-panel:delete-history"
|
||||||
|
className="fixed inset-0 flex items-center justify-center w-screen"
|
||||||
|
>
|
||||||
|
<DialogPanel className="flex flex-col justify-between w-[360px] h-[160px] p-3 text-[#333] dark:text-white/90 border border-[#e6e6e6] bg-white dark:bg-[#202126] dark:border-white/10 shadow-xl rounded-lg">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<DialogTitle className="text-base font-bold">
|
||||||
|
{t("history_list.delete_modal.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<Description className="text-sm">
|
||||||
|
{t("history_list.delete_modal.description", {
|
||||||
|
replace: [active?._source?.title || active?._id],
|
||||||
|
})}
|
||||||
|
</Description>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 self-end">
|
||||||
|
<VisibleKey
|
||||||
|
shortcut="N"
|
||||||
|
shortcutClassName="left-[unset] right-0"
|
||||||
|
onKeyPress={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="h-8 px-4 text-sm text-[#666666] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border border-[#E6E6E6] dark:border-white/10 rounded-lg"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
>
|
||||||
|
{t("history_list.delete_modal.button.cancel")}
|
||||||
|
</button>
|
||||||
|
</VisibleKey>
|
||||||
|
|
||||||
|
<VisibleKey
|
||||||
|
shortcut="Y"
|
||||||
|
shortcutClassName="left-[unset] right-0"
|
||||||
|
onKeyPress={handleRemove}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="h-8 px-4 text-sm text-white bg-[#EF4444] rounded-lg"
|
||||||
|
onClick={handleRemove}
|
||||||
|
>
|
||||||
|
{t("history_list.delete_modal.button.delete")}
|
||||||
|
</button>
|
||||||
|
</VisibleKey>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center flex-1">
|
||||||
|
<NoDataImage />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HistoryList;
|
||||||
21
src/components/Common/Icons/FileIcon/AudioIcon.tsx
Normal file
21
src/components/Common/Icons/FileIcon/AudioIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const AudioIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>audio</title>
|
||||||
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||||
|
<path
|
||||||
|
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AudioIcon;
|
||||||
21
src/components/Common/Icons/FileIcon/VideoIcon.tsx
Normal file
21
src/components/Common/Icons/FileIcon/VideoIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const VideoIcon = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<title>video</title>
|
||||||
|
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||||
|
<path
|
||||||
|
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoIcon;
|
||||||
154
src/components/Common/Icons/FileIcon/index.tsx
Normal file
154
src/components/Common/Icons/FileIcon/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import {
|
||||||
|
FileExcelFilled,
|
||||||
|
FileImageFilled,
|
||||||
|
FileMarkdownFilled,
|
||||||
|
FilePdfFilled,
|
||||||
|
FilePptFilled,
|
||||||
|
FileTextFilled,
|
||||||
|
FileWordFilled,
|
||||||
|
FileZipFilled,
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
|
import AudioIcon from "./AudioIcon";
|
||||||
|
import VideoIcon from "./VideoIcon";
|
||||||
|
import { FC, useMemo } from "react";
|
||||||
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
interface FileIconProps {
|
||||||
|
extname: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileIcon: FC<FileIconProps> = (props) => {
|
||||||
|
const { extname, className } = props;
|
||||||
|
|
||||||
|
const presetFileIcons = [
|
||||||
|
{
|
||||||
|
icon: <FileExcelFilled />,
|
||||||
|
color: "#22b35e",
|
||||||
|
extnames: ["xlsx", "xls", "csv", "xlsm", "xltx", "xltm", "xlsb"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileImageFilled />,
|
||||||
|
color: "#13c2c2",
|
||||||
|
extnames: [
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
"svg",
|
||||||
|
"ico",
|
||||||
|
"tiff",
|
||||||
|
"raw",
|
||||||
|
"heic",
|
||||||
|
"psd",
|
||||||
|
"ai",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileMarkdownFilled />,
|
||||||
|
color: "#722ed1",
|
||||||
|
extnames: ["md", "mdx", "markdown", "mdown", "mkd", "mkdn"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FilePdfFilled />,
|
||||||
|
color: "#ff4d4f",
|
||||||
|
extnames: ["pdf", "xps", "oxps"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FilePptFilled />,
|
||||||
|
color: "#d04423",
|
||||||
|
extnames: [
|
||||||
|
"ppt",
|
||||||
|
"pptx",
|
||||||
|
"pps",
|
||||||
|
"ppsx",
|
||||||
|
"pot",
|
||||||
|
"potx",
|
||||||
|
"pptm",
|
||||||
|
"potm",
|
||||||
|
"ppsm",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileWordFilled />,
|
||||||
|
color: "#1677ff",
|
||||||
|
extnames: ["doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "odt"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <FileZipFilled />,
|
||||||
|
color: "#fab714",
|
||||||
|
extnames: [
|
||||||
|
"zip",
|
||||||
|
"rar",
|
||||||
|
"7z",
|
||||||
|
"tar",
|
||||||
|
"gz",
|
||||||
|
"bz2",
|
||||||
|
"xz",
|
||||||
|
"tgz",
|
||||||
|
"iso",
|
||||||
|
"dmg",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <VideoIcon />,
|
||||||
|
color: "#7b61ff",
|
||||||
|
extnames: [
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"wmv",
|
||||||
|
"flv",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
"m4v",
|
||||||
|
"mpeg",
|
||||||
|
"mpg",
|
||||||
|
"3gp",
|
||||||
|
"rmvb",
|
||||||
|
"ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <AudioIcon />,
|
||||||
|
color: "#eb2f96",
|
||||||
|
extnames: [
|
||||||
|
"mp3",
|
||||||
|
"wav",
|
||||||
|
"flac",
|
||||||
|
"ape",
|
||||||
|
"aac",
|
||||||
|
"ogg",
|
||||||
|
"wma",
|
||||||
|
"m4a",
|
||||||
|
"opus",
|
||||||
|
"ac3",
|
||||||
|
"mid",
|
||||||
|
"midi",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [icon, iconColor] = useMemo(() => {
|
||||||
|
for (const item of presetFileIcons) {
|
||||||
|
const { icon, color, extnames } = item;
|
||||||
|
|
||||||
|
if (extnames.includes(extname)) {
|
||||||
|
return [icon, color];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [<FileTextFilled key="defaultIcon" />, "#8c8c8c"];
|
||||||
|
}, [extname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("text-3xl", className)} style={{ color: iconColor }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileIcon;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user