mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 12:37:45 +01:00
Compare commits
124 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 |
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
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -11,10 +11,12 @@
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"errmsg",
|
||||
"fullscreen",
|
||||
"headlessui",
|
||||
"Icdbb",
|
||||
"icns",
|
||||
"iconfont",
|
||||
"INFINI",
|
||||
"infinilabs",
|
||||
"inputbox",
|
||||
@@ -57,6 +59,7 @@
|
||||
"uuidv",
|
||||
"VITE",
|
||||
"walkdir",
|
||||
"wavesurfer",
|
||||
"webviews",
|
||||
"xzvf",
|
||||
"yuque",
|
||||
|
||||
83
README.md
83
README.md
@@ -1,7 +1,15 @@
|
||||
# Coco AI - Connect & Collaborate
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Tagline**: _"Coco AI - search, connect, collaborate – all in one place."_
|
||||
|
||||
Visit our website: [https://coco.rs](https://coco.rs)
|
||||
|
||||
[](LICENSE) [](https://tauri.app/) [](https://react.dev/) [](https://www.typescriptlang.org/) [](https://www.rust-lang.org/) [](https://nodejs.org/) [](https://github.com/infinilabs/coco-app/pulls) [](https://github.com/infinilabs/coco-app/releases) [](https://github.com/infinilabs/coco-app/actions) [](https://discord.com/invite/4tKTMkkvVX)
|
||||
|
||||
</div>
|
||||
|
||||
Coco AI is a unified search platform that connects all your enterprise applications and data—Google Workspace, Dropbox,
|
||||
Confluent Wiki, GitHub, and more—into a single, powerful search interface. This repository contains the **Coco App**,
|
||||
built for both **desktop and mobile**. The app allows users to search and interact with their enterprise data across
|
||||
@@ -12,16 +20,15 @@ and internal resources. Coco enhances collaboration by making information instan
|
||||
insights based on your enterprise's specific data.
|
||||
|
||||
> **Note**: Backend services, including data indexing and search functionality, are handled in a
|
||||
> separate [repository](https://github.com/infinilabs/coco-server).
|
||||
separate [repository](https://github.com/infinilabs/coco-server).
|
||||
|
||||
## Vision
|
||||

|
||||
|
||||
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco
|
||||
App
|
||||
provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their
|
||||
workspace.
|
||||
## 🚀 Vision
|
||||
|
||||
## Use Cases
|
||||
At Coco AI, we aim to streamline workplace collaboration by centralizing access to enterprise data. The Coco App provides a seamless, cross-platform experience, enabling teams to easily search, connect, and collaborate within their workspace.
|
||||
|
||||
## 💡 Use Cases
|
||||
|
||||
- **Unified Search Across Platforms**: Coco integrates with all your enterprise apps, letting you search documents,
|
||||
conversations, and files across Google Workspace, Dropbox, GitHub, etc.
|
||||
@@ -32,37 +39,67 @@ workspace.
|
||||
- **Simplified Data Access**: By removing the friction between various tools, Coco enhances your workflow and increases
|
||||
productivity.
|
||||
|
||||
## Getting Started
|
||||
## ✨ Key Features
|
||||
|
||||
### Initial Setup
|
||||
- 🔍 **Unified Search**: One-stop enterprise search with multi-platform integration
|
||||
- Supports major collaboration platforms: Google Workspace, Dropbox, Confluence Wiki, GitHub, etc.
|
||||
- Real-time search across documents, conversations, and files
|
||||
- Smart search intent understanding with relevance ranking
|
||||
- Cross-platform data correlation and context display
|
||||
- 🤖 **AI-Powered Chat**: Team-specific ChatGPT-like assistant trained on your enterprise data
|
||||
- 🌐 **Cross-Platform**: Available for Windows, macOS, Linux and Web
|
||||
- 🔒 **Security-First**: Support for private deployment and data sovereignty
|
||||
- ⚡ **High Performance**: Built with Rust and Tauri 2.0
|
||||
- 🎨 **Modern UI**: Sleek interface designed for productivity
|
||||
|
||||
**This version of pnpm requires at least Node.js v18.12**
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
To set up the Coco App for development:
|
||||
- **Frontend**: React + TypeScript
|
||||
- **Desktop Framework**: Tauri 2.0
|
||||
- **Styling**: Tailwind CSS
|
||||
- **State Management**: Zustand
|
||||
- **Build Tool**: Vite
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18.12
|
||||
- Rust (latest stable)
|
||||
- pnpm (package manager)
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
cd coco-app
|
||||
# Install pnpm
|
||||
npm install -g pnpm
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development server
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
#### Desktop Development:
|
||||
|
||||
To start desktop development, run:
|
||||
### Production Build
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Documentation
|
||||
## 📚 Documentation
|
||||
|
||||
For full documentation on Coco AI, please visit the [Coco AI Documentation](https://docs.infinilabs.com/coco-app/main/).
|
||||
- [Coco App Documentation](https://docs.infinilabs.com/coco-app/main/)
|
||||
- [Coco Server Documentation](https://docs.infinilabs.com/coco-server/main/)
|
||||
- [Tauri Documentation](https://tauri.app/)
|
||||
|
||||
## License
|
||||
## 📄 License
|
||||
|
||||
Coco AI is an open-source project licensed under
|
||||
the [MIT License](https://github.com/infinilabs/coco-app/blob/main/LICENSE).
|
||||
Coco AI is an open-source project licensed under the [MIT License](LICENSE). You can freely use, modify, and
|
||||
distribute the software for both personal and commercial purposes, including hosting it on your own servers.
|
||||
|
||||
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.
|
||||
|
||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@ type: docs
|
||||
|
||||
Coco AI is a fully open-source, cross-platform unified search and productivity tool that connects and searches across various data sources, including applications, files, Google Drive, Notion, Yuque, Hugo, and more, both local and cloud-based. By integrating with large models like DeepSeek, Coco AI enables intelligent personal knowledge management, emphasizing privacy and supporting private deployment, helping users quickly and intelligently access their information.
|
||||
|
||||
{{% load-img "/img/screenshot/fusion-search-across-datasources.png" "" %}}
|
||||
{{% load-img "/img/screenshot/coco-chat.png" "" %}}
|
||||
{{% load-img "/img/coco-preview.gif" "" %}}
|
||||
|
||||
For more details on Coco Server, visit: [https://docs.infinilabs.com/coco-app/](https://docs.infinilabs.com/coco-app/).
|
||||
|
||||
|
||||
@@ -9,23 +9,72 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### ❌ Breaking changes
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
### ✈️ Improvements
|
||||
|
||||
## 0.4.0 (2025-04-27)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
- feat: history support for searching, renaming and deleting #322
|
||||
- feat: linux support for application search #330
|
||||
- feat: add shortcuts to most icon buttons #334
|
||||
- feat: add font icon for search list #342
|
||||
- feat: add a border to the main window in Windows 10 #343
|
||||
- feat: mobile terminal adaptation about style #348
|
||||
- feat: service list popup box supports keyboard-only operation #359
|
||||
- feat: networked search data sources support search and keyboard-only operation #367
|
||||
- feat: add application management to the plugin #374
|
||||
- feat: add keyboard-only operation to history list #385
|
||||
- feat: add error notification #386
|
||||
- feat: add support for AI assistant #394
|
||||
- feat: add support for calculator function #399
|
||||
- feat: auto selects the first item after searching #411
|
||||
- feat: web components assistant #422
|
||||
- feat: right-click menu support for search #423
|
||||
- feat: add chat mode launch page #424
|
||||
- feat: add MCP & call LLM tools #430
|
||||
- feat: ai assistant supports search and paging #431
|
||||
- feat: data sources support displaying customized icons #432
|
||||
- feat: add shortcut key conflict hint and reset function #442
|
||||
- feat: updated to include error message #465
|
||||
|
||||
### Bug fix
|
||||
|
||||
- fix: fixed the problem of not being able to search in secondary directories #338
|
||||
- fix: active shadow setting #354
|
||||
- fix: chat history was not show up #377
|
||||
- fix: get attachments in chat sessions
|
||||
- fix: filter http query_args and convert only supported values
|
||||
- fix:fixed several search & chat bugs #412
|
||||
- fix: fixed carriage return problem with chinese input method #464
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: web components #331
|
||||
- refactor: refactoring login callback, receive access_token from coco-server
|
||||
- chore: adjust web component styles #362
|
||||
- style: modify the style #370
|
||||
- style: search list details display #378
|
||||
- refactor: refactoring api error handling #382
|
||||
- chore: update assistant icon & think mode #397
|
||||
- build: build web components and publish #404
|
||||
|
||||
## 0.3.0 (2025-03-31)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- feat: add web pages components #277
|
||||
- feat: support for customizing some of the preset shortcuts #316
|
||||
|
||||
### 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
|
||||
|
||||
@@ -55,10 +104,10 @@ Information about release notes of Coco Server is provided here.
|
||||
### Improvements
|
||||
|
||||
- Refactor: chat components #273
|
||||
- Feat:add endpoint display #282
|
||||
- Feat: add endpoint display #282
|
||||
- Chore: chat window min width & remove input bg #284
|
||||
- Chore: remove selected function & add hide_coco #286
|
||||
- Chore:websocket timeout increased to 2 minutes #289
|
||||
- Chore: websocket timeout increased to 2 minutes #289
|
||||
- Chore: remove chat input border & clear input #295
|
||||
|
||||
## 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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="coco-container">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
13
package.json
13
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:web": "tsc && tsup",
|
||||
"publish:web": "cd dist/search-chat && npm publish",
|
||||
"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",
|
||||
@@ -34,7 +34,9 @@
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.9",
|
||||
"ahooks": "^3.8.4",
|
||||
"axios": "^1.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"dotenv": "^16.4.7",
|
||||
"filesize": "^10.1.6",
|
||||
"i18next": "^23.16.8",
|
||||
@@ -55,9 +57,10 @@
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tauri-plugin-fs-pro-api": "^2.3.1",
|
||||
"tauri-plugin-fs-pro-api": "^2.4.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.2.0",
|
||||
"tauri-plugin-screenshots-api": "^2.1.0",
|
||||
"tauri-plugin-windows-version-api": "^2.0.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.3",
|
||||
@@ -75,9 +78,11 @@
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"cross-env": "^7.0.3",
|
||||
"immer": "^10.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"release-it": "^18.1.2",
|
||||
"sass": "^1.87.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
|
||||
381
pnpm-lock.yaml
generated
381
pnpm-lock.yaml
generated
@@ -56,9 +56,15 @@ importers:
|
||||
ahooks:
|
||||
specifier: ^3.8.4
|
||||
version: 3.8.4(react@18.3.1)
|
||||
axios:
|
||||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
dotenv:
|
||||
specifier: ^16.4.7
|
||||
version: 16.4.7
|
||||
@@ -120,14 +126,17 @@ importers:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
tauri-plugin-fs-pro-api:
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0
|
||||
tauri-plugin-macos-permissions-api:
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
tauri-plugin-screenshots-api:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
tauri-plugin-windows-version-api:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
use-debounce:
|
||||
specifier: ^10.0.4
|
||||
version: 10.0.4(react@18.3.1)
|
||||
@@ -170,10 +179,13 @@ importers:
|
||||
version: 1.8.8
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.3.4(vite@5.4.14(@types/node@22.13.11))
|
||||
version: 4.3.4(vite@5.4.14(@types/node@22.13.11)(sass@1.87.0))
|
||||
autoprefixer:
|
||||
specifier: ^10.4.21
|
||||
version: 10.4.21(postcss@8.5.3)
|
||||
cross-env:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3
|
||||
immer:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
@@ -183,6 +195,9 @@ importers:
|
||||
release-it:
|
||||
specifier: ^18.1.2
|
||||
version: 18.1.2(@types/node@22.13.11)(typescript@5.8.2)
|
||||
sass:
|
||||
specifier: ^1.87.0
|
||||
version: 1.87.0
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.17
|
||||
@@ -197,7 +212,7 @@ importers:
|
||||
version: 5.8.2
|
||||
vite:
|
||||
specifier: ^5.4.14
|
||||
version: 5.4.14(@types/node@22.13.11)
|
||||
version: 5.4.14(@types/node@22.13.11)(sass@1.87.0)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -868,6 +883,88 @@ packages:
|
||||
'@octokit/types@13.10.0':
|
||||
resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1415,6 +1512,9 @@ packages:
|
||||
async-retry@1.3.3:
|
||||
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomically@2.0.3:
|
||||
resolution: {integrity: sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==}
|
||||
|
||||
@@ -1425,6 +1525,9 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
axios@1.8.4:
|
||||
resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==}
|
||||
|
||||
bail@2.0.2:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
@@ -1475,6 +1578,10 @@ packages:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
callsites@3.1.0:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1562,6 +1669,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
comma-separated-tokens@2.0.3:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
@@ -1615,6 +1726,11 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
cross-env@7.0.3:
|
||||
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
|
||||
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1825,10 +1941,19 @@ packages:
|
||||
delaunator@5.0.1:
|
||||
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||
engines: {node: '>=0.10'}
|
||||
hasBin: true
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
@@ -1849,6 +1974,10 @@ packages:
|
||||
resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
@@ -1875,6 +2004,22 @@ packages:
|
||||
error-ex@1.3.2:
|
||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1966,10 +2111,23 @@ packages:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
follow-redirects@1.15.9:
|
||||
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.2:
|
||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
fraction.js@4.3.7:
|
||||
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
|
||||
|
||||
@@ -1992,6 +2150,14 @@ packages:
|
||||
resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-stream@8.0.1:
|
||||
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -2045,6 +2211,10 @@ packages:
|
||||
resolution: {integrity: sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
graceful-fs@4.2.10:
|
||||
resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
|
||||
|
||||
@@ -2054,6 +2224,14 @@ packages:
|
||||
hachure-fill@0.5.2:
|
||||
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2135,6 +2313,9 @@ packages:
|
||||
immer@10.1.1:
|
||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||
|
||||
immutable@5.1.1:
|
||||
resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2430,6 +2611,10 @@ packages:
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
|
||||
|
||||
@@ -2646,6 +2831,9 @@ packages:
|
||||
resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-releases@2.0.19:
|
||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||
|
||||
@@ -3081,6 +3269,11 @@ packages:
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
sass@1.87.0:
|
||||
resolution: {integrity: sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
@@ -3216,8 +3409,8 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tauri-plugin-fs-pro-api@2.3.1:
|
||||
resolution: {integrity: sha512-fx/zITX9MWoDZ603FKWSybluZqJUEOvHU+H6kj3iRJNyoGFHoNkajpQbiK5cu81spQbGBlP9sV2HkaCI07gQ+Q==}
|
||||
tauri-plugin-fs-pro-api@2.4.0:
|
||||
resolution: {integrity: sha512-SfXMQC3SClaHCzEfrQOeSgQvx+YrlfQQpbbtj1U9LJhRjhwpIsC71UqtzcI2J7aJTt5njD+7Oe8daa3mH0Eybg==}
|
||||
|
||||
tauri-plugin-macos-permissions-api@2.2.0:
|
||||
resolution: {integrity: sha512-pgcBqtUMucSColUJ/HcLOBOr+Z+sma2o0nAuobG1uSGBwFJXwltGKE4XsYynktzfzyTpha/xwta2eLezH+EqLg==}
|
||||
@@ -3225,6 +3418,9 @@ packages:
|
||||
tauri-plugin-screenshots-api@2.1.0:
|
||||
resolution: {integrity: sha512-lknHlq7truhBCO4lVlHBWkk/YYrKXNef0mveUftHC3U0LBI/GTNzOFbyZk+VhHAcztTB5BPpZA0TSbuLd9zgQA==}
|
||||
|
||||
tauri-plugin-windows-version-api@2.0.0:
|
||||
resolution: {integrity: sha512-tty5n4ASYbXpnsD5ws2iTcTTpDCrSbzRTVp5Bo3UTpYGqlN1gBn2Zk8s3oO4w7VIM5WtJhDM9Jr/UgoTk7tFJQ==}
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
engines: {node: '>=0.8'}
|
||||
@@ -4123,6 +4319,67 @@ snapshots:
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 24.2.0
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.1':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher@2.5.1':
|
||||
dependencies:
|
||||
detect-libc: 1.0.3
|
||||
is-glob: 4.0.3
|
||||
micromatch: 4.0.8
|
||||
node-addon-api: 7.1.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-arm64': 2.5.1
|
||||
'@parcel/watcher-darwin-x64': 2.5.1
|
||||
'@parcel/watcher-freebsd-x64': 2.5.1
|
||||
'@parcel/watcher-linux-arm-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm-musl': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-arm64-musl': 2.5.1
|
||||
'@parcel/watcher-linux-x64-glibc': 2.5.1
|
||||
'@parcel/watcher-linux-x64-musl': 2.5.1
|
||||
'@parcel/watcher-win32-arm64': 2.5.1
|
||||
'@parcel/watcher-win32-ia32': 2.5.1
|
||||
'@parcel/watcher-win32-x64': 2.5.1
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -4580,14 +4837,14 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.14(@types/node@22.13.11))':
|
||||
'@vitejs/plugin-react@4.3.4(vite@5.4.14(@types/node@22.13.11)(sass@1.87.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10)
|
||||
'@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10)
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.14.2
|
||||
vite: 5.4.14(@types/node@22.13.11)
|
||||
vite: 5.4.14(@types/node@22.13.11)(sass@1.87.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -4650,6 +4907,8 @@ snapshots:
|
||||
dependencies:
|
||||
retry: 0.13.1
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomically@2.0.3:
|
||||
dependencies:
|
||||
stubborn-fs: 1.2.5
|
||||
@@ -4665,6 +4924,14 @@ snapshots:
|
||||
postcss: 8.5.3
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
axios@1.8.4:
|
||||
dependencies:
|
||||
follow-redirects: 1.15.9
|
||||
form-data: 4.0.2
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -4717,6 +4984,11 @@ snapshots:
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
@@ -4791,6 +5063,10 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
@@ -4838,6 +5114,10 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.8.2
|
||||
|
||||
cross-env@7.0.3:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -5065,8 +5345,13 @@ snapshots:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.2
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@1.0.3:
|
||||
optional: true
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
@@ -5085,6 +5370,12 @@ snapshots:
|
||||
|
||||
dotenv@16.4.7: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
electron-to-chromium@1.5.123: {}
|
||||
@@ -5103,6 +5394,21 @@ snapshots:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -5244,11 +5550,20 @@ snapshots:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
follow-redirects@1.15.9: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.2:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
mime-types: 2.1.35
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
|
||||
fs.realpath@1.0.0: {}
|
||||
@@ -5262,6 +5577,24 @@ snapshots:
|
||||
|
||||
get-east-asian-width@1.3.0: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.2
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
get-stream@8.0.1: {}
|
||||
|
||||
get-stream@9.0.1:
|
||||
@@ -5333,12 +5666,20 @@ snapshots:
|
||||
slash: 5.1.0
|
||||
unicorn-magic: 0.1.0
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
graceful-fs@4.2.10: {}
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -5469,6 +5810,8 @@ snapshots:
|
||||
|
||||
immer@10.1.1: {}
|
||||
|
||||
immutable@5.1.1: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -5704,6 +6047,8 @@ snapshots:
|
||||
|
||||
marked@15.0.7: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.2:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
@@ -6160,6 +6505,9 @@ snapshots:
|
||||
dependencies:
|
||||
type-fest: 2.19.0
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
@@ -6683,6 +7031,14 @@ snapshots:
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sass@1.87.0:
|
||||
dependencies:
|
||||
chokidar: 4.0.3
|
||||
immutable: 5.1.1
|
||||
source-map-js: 1.2.1
|
||||
optionalDependencies:
|
||||
'@parcel/watcher': 2.5.1
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -6829,7 +7185,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tauri-plugin-fs-pro-api@2.3.1:
|
||||
tauri-plugin-fs-pro-api@2.4.0:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.4.0
|
||||
|
||||
@@ -6841,6 +7197,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.4.0
|
||||
|
||||
tauri-plugin-windows-version-api@2.0.0:
|
||||
dependencies:
|
||||
'@tauri-apps/api': 2.4.0
|
||||
|
||||
thenify-all@1.6.0:
|
||||
dependencies:
|
||||
thenify: 3.3.1
|
||||
@@ -7021,7 +7381,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.2
|
||||
|
||||
vite@5.4.14(@types/node@22.13.11):
|
||||
vite@5.4.14(@types/node@22.13.11)(sass@1.87.0):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.3
|
||||
@@ -7029,6 +7389,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 22.13.11
|
||||
fsevents: 2.3.3
|
||||
sass: 1.87.0
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
|
||||
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
319
src-tauri/Cargo.lock
generated
319
src-tauri/Cargo.lock
generated
@@ -138,21 +138,20 @@ checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f"
|
||||
|
||||
[[package]]
|
||||
name = "applications"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3854b0be0ff49d616ac728fef23f743a17c0dc304cfd061e28021eb1ea220af"
|
||||
version = "0.3.1"
|
||||
source = "git+https://github.com/infinilabs/applications-rs?rev=fb8f475993a2a774ce08d7a58f9f2ac264248a24#fb8f475993a2a774ce08d7a58f9f2ac264248a24"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"cocoa 0.25.0",
|
||||
"core-foundation 0.9.4",
|
||||
"freedesktop-file-parser",
|
||||
"glob",
|
||||
"image",
|
||||
"ini",
|
||||
"lnk",
|
||||
"log",
|
||||
"objc",
|
||||
"parselnk",
|
||||
"plist",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
@@ -380,6 +379,17 @@ version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
||||
|
||||
[[package]]
|
||||
name = "atty"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
|
||||
dependencies = [
|
||||
"hermit-abi 0.1.19",
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "auto-launch"
|
||||
version = "0.5.0"
|
||||
@@ -406,7 +416,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"arrayvec",
|
||||
"log",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"num-rational",
|
||||
"v_frame",
|
||||
]
|
||||
@@ -713,6 +723,24 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chinese-number"
|
||||
version = "0.7.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49fccaef6346f6d6a741908d3b79fe97c2debe2fbb5eb3a7d00ff5981b52bb6c"
|
||||
dependencies = [
|
||||
"chinese-variant",
|
||||
"enum-ordinalize",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chinese-variant"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7588475145507237ded760e52bf2f1085495245502033756d28ea72ade0e498b"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.40"
|
||||
@@ -728,13 +756,39 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "3.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
|
||||
dependencies = [
|
||||
"atty",
|
||||
"bitflags 1.3.2",
|
||||
"clap_lex",
|
||||
"indexmap 1.9.3",
|
||||
"strsim 0.10.0",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coco"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"applications",
|
||||
"async-trait",
|
||||
"base64 0.13.1",
|
||||
"chinese-number",
|
||||
"dirs 5.0.1",
|
||||
"env_logger",
|
||||
"futures",
|
||||
@@ -745,7 +799,9 @@ dependencies = [
|
||||
"hyper 0.14.32",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"meval",
|
||||
"notify",
|
||||
"num2words",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
"pizza-common",
|
||||
@@ -773,6 +829,7 @@ dependencies = [
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-websocket",
|
||||
"tauri-plugin-windows-version",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
@@ -874,12 +931,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "configparser"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe1d7dcda7d1da79e444bdfba1465f2f849a58b07774e1df473ee77030cb47a7"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -1134,7 +1185,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"strsim 0.11.1",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
@@ -1446,6 +1497,26 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5"
|
||||
dependencies = [
|
||||
"enum-ordinalize-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-ordinalize-derive"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.100",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.11"
|
||||
@@ -1573,7 +1644,7 @@ version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
|
||||
dependencies = [
|
||||
"memoffset 0.9.1",
|
||||
"memoffset",
|
||||
"rustc_version",
|
||||
]
|
||||
|
||||
@@ -1671,6 +1742,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "freedesktop-file-parser"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6059d3997cc694ec3e9a378db855866233ef7edfeafd85afcb2239fd130e6e6b"
|
||||
dependencies = [
|
||||
"thiserror 2.0.12",
|
||||
"xdgkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -2299,6 +2380,15 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.4.0"
|
||||
@@ -2753,15 +2843,6 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ini"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a9271a5dfd4228fa56a78d7508a35c321639cc71f783bb7a5723552add87bce"
|
||||
dependencies = [
|
||||
"configparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
@@ -3087,6 +3168,12 @@ dependencies = [
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -3217,15 +3304,6 @@ version = "2.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
@@ -3235,6 +3313,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "meval"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f79496a5651c8d57cd033c5add8ca7ee4e3d5f7587a4777484640d9cb60392d9"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom 1.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3370,19 +3458,6 @@ version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"memoffset 0.7.1",
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.29.0"
|
||||
@@ -3393,7 +3468,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"memoffset 0.9.1",
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3402,6 +3477,12 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5b8c256fd9471521bcb84c3cdba98921497f1a331cbc15b8030fc63b82050ce"
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
@@ -3436,6 +3517,12 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigfloat"
|
||||
version = "1.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "793a9a2afbf2b4fc1b9a47de731031b06ed362a5ede3681fbfbaeb2ad4faaa13"
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -3503,6 +3590,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num2words"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ab78b987a0e1e6cf869a443f7f1c9dc696117d47780227e961e742d0b45d706"
|
||||
dependencies = [
|
||||
"num-bigfloat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.3"
|
||||
@@ -3960,6 +4056,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
@@ -4274,7 +4376,7 @@ checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"hermit-abi 0.4.0",
|
||||
"pin-project-lite",
|
||||
"rustix 0.38.44",
|
||||
"tracing",
|
||||
@@ -4434,6 +4536,16 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0452695941410a58c8ce4391707ba9bad26a247173bd9886a05a5e8a8babec75"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.30.0"
|
||||
@@ -4878,28 +4990,6 @@ dependencies = [
|
||||
"trim-in-place",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustbus"
|
||||
version = "0.19.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4054c51e241d0c6d57fab9fecda2223b7bdc7621be67f0d70568d94a6762f281"
|
||||
dependencies = [
|
||||
"nix 0.26.4",
|
||||
"rustbus_derive",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustbus_derive"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8dcb6a55a8a297bb62066b114624aac082ac1a330d90a0d5b336645208e29ae2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.24"
|
||||
@@ -5323,18 +5413,6 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "showfile"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "517f72ec91adba6aa0f0d680c0a685a171f30ae4a9a402e955f427ab6a29cdf4"
|
||||
dependencies = [
|
||||
"objc",
|
||||
"rustbus",
|
||||
"urlencoding",
|
||||
"windows 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.2"
|
||||
@@ -5481,6 +5559,12 @@ dependencies = [
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -5919,17 +6003,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs-pro"
|
||||
version = "2.3.1"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88311267fcdd4221e1b86c8aedf4f8e33ea0ea33e28718624fe2bc7405935a84"
|
||||
checksum = "4be88c01baab2a859bae736f55ecd3f6a5aa548c8a1f15758644e31cd6a5eaf9"
|
||||
dependencies = [
|
||||
"file_icon_provider",
|
||||
"flate2",
|
||||
"fs_extra",
|
||||
"image",
|
||||
"open",
|
||||
"serde",
|
||||
"showfile",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
@@ -6134,6 +6216,19 @@ dependencies = [
|
||||
"tokio-tungstenite 0.26.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-windows-version"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a116b1451af25fab5649f53bc6d8192bb65238b5817c94ea4f5a55bc6725a8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.12",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.5.0"
|
||||
@@ -6253,6 +6348,21 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termcolor"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
|
||||
dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
|
||||
|
||||
[[package]]
|
||||
name = "thin-slice"
|
||||
version = "0.1.1"
|
||||
@@ -6341,6 +6451,12 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tini"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e004df4c5f0805eb5f55883204a514cfa43a6d924741be29e871753a53d5565a"
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
@@ -6702,7 +6818,7 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset 0.9.1",
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"winapi",
|
||||
]
|
||||
@@ -6784,12 +6900,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -7926,6 +8036,27 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdgkit"
|
||||
version = "3.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeac9c0125f3c131c6a2898d2a9f25c11b7954c3ff644a018cb9e06fa92919b"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"quick-xml 0.21.0",
|
||||
"serde",
|
||||
"tini",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.5"
|
||||
@@ -7971,7 +8102,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
@@ -27,7 +27,9 @@ pizza-common = { git = "https://github.com/infinilabs/pizza-common", branch = "m
|
||||
tauri = { version = "2", features = ["protocol-asset", "macos-private-api", "tray-icon", "image-ico", "image-png", "unstable"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Need `arbitrary_precision` feature to support storing u128
|
||||
# see: https://docs.rs/serde_json/latest/serde_json/struct.Number.html#method.from_u128
|
||||
serde_json = { version = "1", features = ["arbitrary_precision"] }
|
||||
tauri-plugin-http = "2"
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-deep-link = "2.0.0"
|
||||
@@ -41,7 +43,7 @@ tauri-plugin-drag = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-fs-pro = "2"
|
||||
tauri-plugin-screenshots = "2"
|
||||
applications = "0.3.0"
|
||||
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "fb8f475993a2a774ce08d7a58f9f2ac264248a24" }
|
||||
|
||||
tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
@@ -69,6 +71,10 @@ http = "1.1.0"
|
||||
tungstenite = "0.24.0"
|
||||
env_logger = "0.11.5"
|
||||
tokio-util = "0.7.14"
|
||||
tauri-plugin-windows-version = "2"
|
||||
meval = "0.2"
|
||||
chinese-number = "0.7"
|
||||
num2words = "1"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:deny-internal-toggle-maximize",
|
||||
"core:window:allow-set-shadow",
|
||||
"core:app:allow-set-app-theme",
|
||||
"shell:default",
|
||||
"http:default",
|
||||
@@ -68,6 +69,7 @@
|
||||
"screenshots:default",
|
||||
"core:window:allow-set-theme",
|
||||
"process:default",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
"windows-version:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::common;
|
||||
use crate::common::assistant::ChatRequestMessage;
|
||||
use crate::common::http::GetResponse;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use reqwest::Response;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
@@ -12,6 +12,7 @@ pub async fn chat_history<R: Runtime>(
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params: HashMap<String, Value> = HashMap::new();
|
||||
if from > 0 {
|
||||
@@ -21,25 +22,20 @@ pub async fn chat_history<R: Runtime>(
|
||||
query_params.insert("size".to_string(), size.into());
|
||||
}
|
||||
|
||||
if let Some(query) = query {
|
||||
if !query.is_empty() {
|
||||
query_params.insert("query".to_string(), query.into());
|
||||
}
|
||||
}
|
||||
|
||||
let response = HttpClient::get(&server_id, "/chat/_history", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Error get sessions: {}", e))?;
|
||||
.map_err(|e| {
|
||||
dbg!("Error get history: {}", &e);
|
||||
format!("Error get history: {}", e)
|
||||
})?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
}
|
||||
|
||||
async fn handle_raw_response(response: Response) -> Result<Result<String, String>, String> {
|
||||
Ok(
|
||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
||||
Err("Failed to send message".to_string())
|
||||
} else {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
Ok(body)
|
||||
},
|
||||
)
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -64,7 +60,7 @@ pub async fn session_chat_history<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error get session message: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -80,7 +76,7 @@ pub async fn open_session_chat<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error open session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -96,7 +92,7 @@ pub async fn close_session_chat<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error close session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
@@ -111,7 +107,7 @@ pub async fn cancel_session_chat<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -120,14 +116,17 @@ pub async fn new_chat<R: Runtime>(
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
) -> Result<GetResponse, String> {
|
||||
let body = if !message.is_empty() {
|
||||
let message = ChatRequestMessage {
|
||||
message: Some(message),
|
||||
};
|
||||
let body = reqwest::Body::from(serde_json::to_string(&message).unwrap());
|
||||
Some(body)
|
||||
Some(
|
||||
serde_json::to_string(&message)
|
||||
.map_err(|e| format!("Failed to serialize message: {}", e))?
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -135,20 +134,16 @@ pub async fn new_chat<R: Runtime>(
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let response = HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
let response =
|
||||
HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
if response.status().as_u16() < 200 || response.status().as_u16() >= 400 {
|
||||
return Err("Failed to send message".to_string());
|
||||
}
|
||||
let body_text = common::http::get_response_body_text(response).await?;
|
||||
|
||||
let chat_response: GetResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
let chat_response: GetResponse =
|
||||
serde_json::from_str(&body_text).map_err(|e| format!("Failed to parse response JSON: {}", e))?;
|
||||
|
||||
// Check the result and status fields
|
||||
if chat_response.result != "created" {
|
||||
return Err(format!("Unexpected result: {}", chat_response.result));
|
||||
}
|
||||
@@ -174,10 +169,90 @@ pub async fn send_message<R: Runtime>(
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||
let response =
|
||||
HttpClient::advanced_post(&server_id, path.as_str(), Some(headers), query_params, Some(body))
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
let response = HttpClient::advanced_post(
|
||||
&server_id,
|
||||
path.as_str(),
|
||||
Some(headers),
|
||||
query_params,
|
||||
Some(body),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
handle_raw_response(response).await?
|
||||
common::http::get_response_body_text(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_session_chat(server_id: String, session_id: String) -> Result<bool, String> {
|
||||
let response =
|
||||
HttpClient::delete(&server_id, &format!("/chat/{}", session_id), None, None).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(format!("Delete failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_session_chat(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
title: Option<String>,
|
||||
context: Option<HashMap<String, Value>>,
|
||||
) -> Result<bool, String> {
|
||||
let mut body = HashMap::new();
|
||||
if let Some(title) = title {
|
||||
body.insert("title".to_string(), Value::String(title));
|
||||
}
|
||||
if let Some(context) = context {
|
||||
body.insert(
|
||||
"context".to_string(),
|
||||
Value::Object(context.into_iter().collect()),
|
||||
);
|
||||
}
|
||||
|
||||
let response = HttpClient::put(
|
||||
&server_id,
|
||||
&format!("/chat/{}", session_id),
|
||||
None,
|
||||
None,
|
||||
Some(reqwest::Body::from(serde_json::to_string(&body).unwrap())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error updating session: {}", e))?;
|
||||
|
||||
Ok(response.status().is_success())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assistant_search<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
query: Option<HashMap<String, Value>>,
|
||||
) -> Result<Value, String> {
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if let Some(q) = query {
|
||||
body["query"] = serde_json::to_value(q).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let response = HttpClient::post(
|
||||
&server_id,
|
||||
"/assistant/_search",
|
||||
None,
|
||||
Some(reqwest::Body::from(body.to_string())),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Error searching assistants: {}", e))?;
|
||||
|
||||
response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ pub struct EditorInfo {
|
||||
pub timestamp: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Document {
|
||||
pub id: String,
|
||||
pub created: Option<String>,
|
||||
@@ -55,8 +55,15 @@ pub struct Document {
|
||||
pub owner: Option<UserInfo>,
|
||||
pub last_updated_by: Option<EditorInfo>,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
pub fn new(source: Option<DataSourceReference>, id: String, category: String, name: String, url: String) -> Self {
|
||||
pub fn new(
|
||||
source: Option<DataSourceReference>,
|
||||
id: String,
|
||||
category: String,
|
||||
name: String,
|
||||
url: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
created: None,
|
||||
|
||||
45
src-tauri/src/common/error.rs
Normal file
45
src-tauri/src/common/error.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorDetail {
|
||||
pub reason: String,
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum SearchError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Invalid response format: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SearchError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_timeout() {
|
||||
SearchError::Timeout
|
||||
} else if err.is_decode() {
|
||||
SearchError::ParseError(err.to_string())
|
||||
} else {
|
||||
SearchError::HttpError(err.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::common;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -15,4 +17,34 @@ pub struct Source {
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
pub async fn get_response_body_text(response: Response) -> Result<String, String> {
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
if status < 200 || status >= 400 {
|
||||
// Try to parse the error body
|
||||
let fallback_error = "Failed to send message".to_string();
|
||||
|
||||
if body.trim().is_empty() {
|
||||
return Err(fallback_error);
|
||||
}
|
||||
|
||||
match serde_json::from_str::<common::error::ErrorResponse>(&body) {
|
||||
Ok(parsed_error) => {
|
||||
dbg!(&parsed_error);
|
||||
Err(format!(
|
||||
"Server error ({}): {}",
|
||||
parsed_error.error.status, parsed_error.error.reason
|
||||
))
|
||||
}
|
||||
Err(_) => Err(fallback_error),
|
||||
}
|
||||
} else {
|
||||
Ok(body)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod traits;
|
||||
pub mod register;
|
||||
pub mod assistant;
|
||||
pub mod http;
|
||||
pub mod error;
|
||||
|
||||
pub static MAIN_WINDOW_LABEL: &str = "main";
|
||||
pub static SETTINGS_WINDOW_LABEL: &str = "settings";
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Preferences {
|
||||
pub theme: String,
|
||||
pub language: String,
|
||||
pub theme: Option<String>,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug,Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub avatar: String,
|
||||
pub preferences: Preferences,
|
||||
pub avatar: Option<String>,
|
||||
pub preferences: Option<Preferences>,
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use reqwest::Response;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SearchResponse<T> {
|
||||
pub took: u64,
|
||||
@@ -47,14 +48,11 @@ pub async fn parse_search_response<T>(
|
||||
where
|
||||
T: for<'de> Deserialize<'de> + std::fmt::Debug,
|
||||
{
|
||||
let body = response
|
||||
.json::<Value>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse JSON: {}", e))?;
|
||||
let body_text = get_response_body_text(response).await?;
|
||||
|
||||
// dbg!(&body);
|
||||
// dbg!(&body_text);
|
||||
|
||||
let search_response: SearchResponse<T> = serde_json::from_value(body)
|
||||
let search_response: SearchResponse<T> = serde_json::from_str(&body_text)
|
||||
.map_err(|e| format!("Failed to deserialize search response: {}", e))?;
|
||||
|
||||
Ok(search_response)
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use thiserror::Error;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use crate::common::error::SearchError;
|
||||
// use std::{future::Future, pin::Pin};
|
||||
use crate::common::search::SearchQuery;
|
||||
use serde::Serialize;
|
||||
use crate::common::search::{QueryResponse, QuerySource};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[async_trait]
|
||||
pub trait SearchSource: Send + Sync {
|
||||
@@ -13,34 +11,3 @@ pub trait SearchSource: Send + Sync {
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Serialize)]
|
||||
pub enum SearchError {
|
||||
#[error("HTTP request failed: {0}")]
|
||||
HttpError(String),
|
||||
|
||||
#[error("Invalid response format: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Timeout occurred")]
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Mutex;
|
||||
use tauri::async_runtime::block_on;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tokio::runtime::Runtime as RT;
|
||||
|
||||
/// Tauri store name
|
||||
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
|
||||
@@ -82,7 +82,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_screenshots::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
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -110,7 +111,8 @@ pub fn run() {
|
||||
server::servers::disable_server,
|
||||
server::auth::handle_sso_callback,
|
||||
server::profile::get_user_profiles,
|
||||
server::datasource::get_datasources_by_server,
|
||||
server::datasource::datasource_search,
|
||||
server::datasource::mcp_server_search,
|
||||
server::connector::get_connectors_by_server,
|
||||
search::query_coco_fusion,
|
||||
assistant::chat_history,
|
||||
@@ -120,6 +122,9 @@ pub fn run() {
|
||||
assistant::open_session_chat,
|
||||
assistant::close_session_chat,
|
||||
assistant::cancel_session_chat,
|
||||
assistant::delete_session_chat,
|
||||
assistant::update_session_chat,
|
||||
assistant::assistant_search,
|
||||
// server::get_coco_server_datasources,
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
@@ -128,7 +133,11 @@ pub fn run() {
|
||||
server::attachment::upload_attachment,
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription
|
||||
server::transcription::transcription,
|
||||
local::application::get_default_search_paths,
|
||||
local::application::list_app_with_metadata_in,
|
||||
util::open,
|
||||
server::system_settings::get_system_settings
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = SearchSourceRegistry::default();
|
||||
@@ -136,19 +145,11 @@ pub fn run() {
|
||||
app.manage(registry); // Store registry in Tauri's app state
|
||||
app.manage(server::websocket::WebSocketManager::default());
|
||||
|
||||
// Get app handle
|
||||
// let app_handle = app.handle().clone();
|
||||
|
||||
// Create a single Tokio runtime instance
|
||||
let rt = RT::new().expect("Failed to create Tokio runtime");
|
||||
|
||||
// Use the runtime to spawn the async initialization tasks
|
||||
let init_app_handle = app.handle().clone();
|
||||
rt.spawn(async move {
|
||||
init(&init_app_handle).await; // Pass a reference to `app_handle`
|
||||
block_on(async {
|
||||
init(app.handle()).await;
|
||||
});
|
||||
|
||||
shortcut::enable_shortcut(&app);
|
||||
shortcut::enable_shortcut(app);
|
||||
|
||||
enable_autostart(app);
|
||||
|
||||
@@ -237,10 +238,12 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
let application_search =
|
||||
local::application::ApplicationSearchSource::new(app_handle.clone(), 1000f64).await?;
|
||||
let calculator_search = local::calculator::CalculatorSource::new(2000f64);
|
||||
|
||||
// Register the application search source
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
registry.register_source(application_search).await;
|
||||
registry.register_source(calculator_search).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,21 +1,196 @@
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::search::{QueryResponse, QuerySource, SearchQuery};
|
||||
use crate::common::traits::{SearchError, SearchSource};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::local::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use applications::{AppInfo, AppInfoContext};
|
||||
use applications::App;
|
||||
use async_trait::async_trait;
|
||||
use base64::encode;
|
||||
use fuzzy_prefix_search::Trie;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
use tauri_plugin_fs_pro::{icon, name};
|
||||
use tauri_plugin_fs_pro::{icon, metadata, name, IconOptions};
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Applications";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_default_search_paths() -> Vec<String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return vec![
|
||||
"/Applications".into(),
|
||||
"/System/Applications".into(),
|
||||
"/System/Library/CoreServices".into(),
|
||||
];
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
let paths = applications::get_default_search_paths();
|
||||
let mut ret = Vec::with_capacity(paths.len());
|
||||
for search_path in paths {
|
||||
let path_string = search_path
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.expect("path should be UTF-8 encoded");
|
||||
|
||||
ret.push(path_string);
|
||||
}
|
||||
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows: return the path to application's exe
|
||||
/// * macOS: return the path to the `.app` bundle
|
||||
/// * Linux: return the path to the `.desktop` file
|
||||
fn get_app_path(app: &App) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
app.icon_path.is_some(),
|
||||
"we only accept Applications with icons"
|
||||
);
|
||||
app.app_path_exe
|
||||
.as_ref()
|
||||
.expect("icon is Some, exe path should be Some as well")
|
||||
.to_path_buf()
|
||||
} else {
|
||||
app.app_desktop_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return `app`'s path.
|
||||
///
|
||||
/// * Windows/macOS: extract `app_path`'s file name and remove the file extension
|
||||
/// * Linux: return the name specified in `.desktop` file
|
||||
async fn get_app_name(app: &App) -> String {
|
||||
if cfg!(target_os = "linux") {
|
||||
app.name.clone()
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
name(app_path.clone()).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to return an absolute path to `app`'s icon.
|
||||
///
|
||||
/// On macOS/Windows, we cache icons in our data directory using the `icon()` function.
|
||||
async fn get_app_icon_path<R: Runtime>(
|
||||
tauri_app_handle: &AppHandle<R>,
|
||||
app: &App,
|
||||
) -> Result<PathBuf, String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let icon_path = app
|
||||
.icon_path
|
||||
.as_ref()
|
||||
.expect("We only accept applications with icons")
|
||||
.to_path_buf();
|
||||
|
||||
Ok(icon_path)
|
||||
} else {
|
||||
let app_path = get_app_path(app);
|
||||
let options = IconOptions {
|
||||
size: Some(256),
|
||||
save_path: None,
|
||||
};
|
||||
|
||||
icon(tauri_app_handle.clone(), app_path, Some(options))
|
||||
.await
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Return all the Apps found under `search_path`.
|
||||
///
|
||||
/// Note: apps with no icons will be filtered out.
|
||||
fn list_app_in(search_path: Vec<String>) -> Result<Vec<App>, String> {
|
||||
let search_path = search_path
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let apps = applications::get_all_apps(&search_path).map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(apps
|
||||
.into_iter()
|
||||
.filter(|app| app.icon_path.is_some())
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AppMetadata {
|
||||
name: String,
|
||||
r#where: PathBuf,
|
||||
size: u64,
|
||||
icon: PathBuf,
|
||||
created: u128,
|
||||
modified: u128,
|
||||
last_opened: u128,
|
||||
}
|
||||
|
||||
/// List apps that are in the `search_path`.
|
||||
///
|
||||
/// Different from `list_app_in()`, every app is JSON object containing its metadata, e.g.:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "name": "Finder",
|
||||
/// "where": "/System/Library/CoreServices",
|
||||
/// "size": 49283072,
|
||||
/// "icon": "/xxx.png",
|
||||
/// "created": 1744625204,
|
||||
/// "modified": 1744625204,
|
||||
/// "lastOpened": 1744625250
|
||||
/// }
|
||||
/// ```
|
||||
#[tauri::command]
|
||||
pub async fn list_app_with_metadata_in<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
search_path: Vec<String>,
|
||||
) -> Result<Vec<AppMetadata>, String> {
|
||||
let apps = list_app_in(search_path)?;
|
||||
|
||||
let mut apps_with_meta = Vec::with_capacity(apps.len());
|
||||
|
||||
// name version where Type(hardcoded Application) Size Created Modify
|
||||
for app in apps.iter() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_path_where = {
|
||||
let mut app_path_clone = app_path.clone();
|
||||
let truncated = app_path_clone.pop();
|
||||
if !truncated {
|
||||
panic!("every app file should live somewhere");
|
||||
}
|
||||
|
||||
app_path_clone
|
||||
};
|
||||
let icon = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
let raw_app_metadata = metadata(app_path.clone(), None).await?;
|
||||
|
||||
let app_metadata = AppMetadata {
|
||||
name: app_name,
|
||||
r#where: app_path_where,
|
||||
size: raw_app_metadata.size,
|
||||
icon,
|
||||
created: raw_app_metadata.created_at,
|
||||
modified: raw_app_metadata.modified_at,
|
||||
last_opened: raw_app_metadata.accessed_at,
|
||||
};
|
||||
|
||||
apps_with_meta.push(app_metadata);
|
||||
}
|
||||
|
||||
Ok(apps_with_meta)
|
||||
}
|
||||
|
||||
pub struct ApplicationSearchSource {
|
||||
base_score: f64,
|
||||
// app name -> app icon path
|
||||
icons: HashMap<String, PathBuf>,
|
||||
application_paths: Trie<String>,
|
||||
application_paths: Trie<PathBuf>,
|
||||
}
|
||||
|
||||
impl ApplicationSearchSource {
|
||||
@@ -26,34 +201,20 @@ impl ApplicationSearchSource {
|
||||
let application_paths = Trie::new();
|
||||
let mut icons = HashMap::new();
|
||||
|
||||
let mut ctx = AppInfoContext::new(vec![]);
|
||||
ctx.refresh_apps().map_err(|err| err.to_string())?; // must refresh apps before getting them
|
||||
let apps = ctx.get_all_apps();
|
||||
let default_search_path = get_default_search_paths();
|
||||
let apps = list_app_in(default_search_path)?;
|
||||
|
||||
for app in &apps {
|
||||
if app.icon_path.is_none() {
|
||||
let app_path = get_app_path(app);
|
||||
let app_name = get_app_name(app).await;
|
||||
let app_icon_path = get_app_icon_path(&app_handle, app).await?;
|
||||
|
||||
if app_name.is_empty() || app_name.eq("Coco-AI") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = if cfg!(target_os = "macos") {
|
||||
app.app_desktop_path.clone()
|
||||
} else {
|
||||
app.app_path_exe
|
||||
.clone()
|
||||
.unwrap_or(PathBuf::from("Path not found"))
|
||||
};
|
||||
let search_word = name(path.clone()).await;
|
||||
let icon = icon(app_handle.clone(), path.clone(), Some(256))
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let path_string = path.to_string_lossy().into_owned();
|
||||
|
||||
if search_word.is_empty() || search_word.eq("coco-ai") {
|
||||
continue;
|
||||
}
|
||||
|
||||
application_paths.insert(&search_word, path_string.clone());
|
||||
icons.insert(path_string, icon);
|
||||
application_paths.insert(&app_name, app_path);
|
||||
icons.insert(app_name, app_icon_path);
|
||||
}
|
||||
|
||||
Ok(ApplicationSearchSource {
|
||||
@@ -73,7 +234,7 @@ impl SearchSource for ApplicationSearchSource {
|
||||
.unwrap_or("My Computer".into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: "local_applications".into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +256,10 @@ impl SearchSource for ApplicationSearchSource {
|
||||
let mut total_hits = 0;
|
||||
let mut hits = Vec::new();
|
||||
|
||||
let query_string_len = query_string.len();
|
||||
let mut results = self
|
||||
.application_paths
|
||||
.search_within_distance_scored(&query_string, 3);
|
||||
.search_within_distance_scored(&query_string, query_string_len - 1);
|
||||
|
||||
// Check for NaN or extreme score values and handle them properly
|
||||
results.sort_by(|a, b| {
|
||||
@@ -114,31 +276,28 @@ impl SearchSource for ApplicationSearchSource {
|
||||
|
||||
if !results.is_empty() {
|
||||
for result in results {
|
||||
let file_name_str = result.word;
|
||||
let file_path_str = result.data.get(0).unwrap().to_string();
|
||||
let file_path = PathBuf::from(file_path_str.clone());
|
||||
let cleaned_file_name = name(file_path).await;
|
||||
let app_name = result.word;
|
||||
let app_path = result.data.first().unwrap().clone();
|
||||
let app_path_string = app_path.to_string_lossy().into_owned();
|
||||
|
||||
total_hits += 1;
|
||||
|
||||
let mut doc = Document::new(
|
||||
Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some("Applications".into()),
|
||||
id: Some(file_name_str.clone()),
|
||||
icon: None,
|
||||
}),
|
||||
file_path_str.clone(),
|
||||
"Application".to_string(),
|
||||
cleaned_file_name,
|
||||
file_path_str.clone(),
|
||||
);
|
||||
Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
app_path_string.clone(),
|
||||
"Application".to_string(),
|
||||
app_name.clone(),
|
||||
app_path_string.clone(),
|
||||
);
|
||||
|
||||
// Attach icon if available
|
||||
if let Some(icon_path) = self.icons.get(file_path_str.as_str()) {
|
||||
// doc.icon = Some(format!("file://{}", icon_path.to_string_lossy()));
|
||||
// dbg!(&doc.icon);
|
||||
if let Ok(icon_data) = read_icon_and_encode(icon_path) {
|
||||
doc.icon = Some(format!("data:image/png;base64,{}", icon_data));
|
||||
}
|
||||
if let Some(icon_path) = self.icons.get(app_name.as_str()) {
|
||||
doc.icon = Some(icon_path.as_os_str().to_str().unwrap().to_string());
|
||||
}
|
||||
|
||||
hits.push((doc, self.base_score + result.score as f64));
|
||||
@@ -152,12 +311,3 @@ impl SearchSource for ApplicationSearchSource {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Function to read the icon file and convert it to base64
|
||||
fn read_icon_and_encode(icon_path: &Path) -> Result<String, std::io::Error> {
|
||||
// Read the icon file as binary data
|
||||
let icon_data = fs::read(icon_path)?;
|
||||
|
||||
// Encode the data to base64
|
||||
Ok(encode(&icon_data))
|
||||
}
|
||||
|
||||
163
src-tauri/src/local/calculator.rs
Normal file
163
src-tauri/src/local/calculator.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use super::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::common::{
|
||||
document::{DataSourceReference, Document},
|
||||
error::SearchError,
|
||||
search::{QueryResponse, QuerySource, SearchQuery},
|
||||
traits::SearchSource,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chinese_number::{ChineseCase, ChineseCountMethod, ChineseVariant, NumberToChinese};
|
||||
use num2words::Num2Words;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const DATA_SOURCE_ID: &str = "Calculator";
|
||||
|
||||
pub struct CalculatorSource {
|
||||
base_score: f64,
|
||||
}
|
||||
|
||||
impl CalculatorSource {
|
||||
pub fn new(base_score: f64) -> Self {
|
||||
CalculatorSource { base_score }
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query(query: String) -> Value {
|
||||
let mut query_json = serde_json::Map::new();
|
||||
|
||||
let operators = ["+", "-", "*", "/", "%"];
|
||||
|
||||
let found_operators: Vec<_> = query
|
||||
.chars()
|
||||
.filter(|c| operators.contains(&c.to_string().as_str()))
|
||||
.collect();
|
||||
|
||||
if found_operators.len() == 1 {
|
||||
let operation = match found_operators[0] {
|
||||
'+' => "sum",
|
||||
'-' => "subtract",
|
||||
'*' => "multiply",
|
||||
'/' => "divide",
|
||||
'%' => "remainder",
|
||||
_ => "expression",
|
||||
};
|
||||
|
||||
query_json.insert("type".to_string(), Value::String(operation.to_string()));
|
||||
} else {
|
||||
query_json.insert("type".to_string(), Value::String("expression".to_string()));
|
||||
}
|
||||
|
||||
query_json.insert("value".to_string(), Value::String(query));
|
||||
|
||||
Value::Object(query_json)
|
||||
}
|
||||
|
||||
fn parse_result(num: f64) -> Value {
|
||||
let mut result_json = serde_json::Map::new();
|
||||
|
||||
let to_zh = num
|
||||
.to_chinese(
|
||||
ChineseVariant::Simple,
|
||||
ChineseCase::Upper,
|
||||
ChineseCountMethod::TenThousand,
|
||||
)
|
||||
.unwrap_or(num.to_string());
|
||||
|
||||
let to_en = Num2Words::new(num)
|
||||
.to_words()
|
||||
.map(|s| {
|
||||
let mut chars = s.chars();
|
||||
let mut result = String::new();
|
||||
let mut capitalize = true;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ' ' || c == '-' {
|
||||
result.push(c);
|
||||
capitalize = true;
|
||||
} else if capitalize {
|
||||
result.extend(c.to_uppercase());
|
||||
capitalize = false;
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
})
|
||||
.unwrap_or(num.to_string());
|
||||
|
||||
result_json.insert("value".to_string(), Value::String(num.to_string()));
|
||||
result_json.insert("toZh".to_string(), Value::String(to_zh));
|
||||
result_json.insert("toEn".to_string(), Value::String(to_en));
|
||||
|
||||
Value::Object(result_json)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchSource for CalculatorSource {
|
||||
fn get_type(&self) -> QuerySource {
|
||||
QuerySource {
|
||||
r#type: LOCAL_QUERY_SOURCE_TYPE.into(),
|
||||
name: hostname::get()
|
||||
.unwrap_or(DATA_SOURCE_ID.into())
|
||||
.to_string_lossy()
|
||||
.into(),
|
||||
id: DATA_SOURCE_ID.into(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let query_string = query
|
||||
.query_strings
|
||||
.get("query")
|
||||
.unwrap_or(&"".to_string())
|
||||
.to_string();
|
||||
|
||||
if query_string.is_empty() || query_string.len() == 1 {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
match meval::eval_str(&query_string) {
|
||||
Ok(num) => {
|
||||
let mut payload: HashMap<String, Value> = HashMap::new();
|
||||
|
||||
let payload_query = parse_query(query_string);
|
||||
let payload_result = parse_result(num);
|
||||
|
||||
payload.insert("query".to_string(), payload_query);
|
||||
payload.insert("result".to_string(), payload_result);
|
||||
|
||||
let doc = Document {
|
||||
id: DATA_SOURCE_ID.to_string(),
|
||||
category: Some(DATA_SOURCE_ID.to_string()),
|
||||
payload: Some(payload),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(DATA_SOURCE_ID.into()),
|
||||
id: Some(DATA_SOURCE_ID.into()),
|
||||
icon: None,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: vec![(doc, self.base_score)],
|
||||
total_hits: 1,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits: Vec::new(),
|
||||
total_hits: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
pub mod file_system;
|
||||
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
pub const LOCAL_QUERY_SOURCE_TYPE: &str = "local";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::search::{
|
||||
FailedRequest, MultiSourceQueryResponse, QueryHits, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::traits::SearchError;
|
||||
use futures::stream::FuturesUnordered;
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
@@ -16,7 +16,10 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: HashMap<String, String>,
|
||||
query_timeout: u64,
|
||||
) -> Result<MultiSourceQueryResponse, SearchError> {
|
||||
let query_source_to_search = query_strings.get("querysource");
|
||||
|
||||
let search_sources = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
let sources_future = search_sources.get_sources();
|
||||
@@ -26,11 +29,19 @@ pub async fn query_coco_fusion<R: Runtime>(
|
||||
let sources_list = sources_future.await;
|
||||
|
||||
// Time limit for each query
|
||||
let timeout_duration = Duration::from_millis(500); //TODO, settings
|
||||
let timeout_duration = Duration::from_millis(query_timeout);
|
||||
|
||||
// Push all queries into futures
|
||||
for query_source in sources_list {
|
||||
let query_source_type = query_source.get_type().clone();
|
||||
|
||||
if let Some(query_source_to_search) = query_source_to_search {
|
||||
// We should not search this data source
|
||||
if &query_source_type.id != query_source_to_search {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
sources.insert(query_source_type.id.clone(), query_source_type);
|
||||
|
||||
let query = SearchQuery::new(from, size, query_strings.clone());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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};
|
||||
@@ -29,16 +30,16 @@ pub struct AttachmentSource {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHit {
|
||||
pub _index: String,
|
||||
pub _type: String,
|
||||
pub _type: Option<String>,
|
||||
pub _id: String,
|
||||
pub _score: f64,
|
||||
pub _score: Option<f64>,
|
||||
pub _source: AttachmentSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHits {
|
||||
pub total: Value,
|
||||
pub max_score: f64,
|
||||
pub max_score: Option<f64>,
|
||||
pub hits: Vec<AttachmentHit>,
|
||||
}
|
||||
|
||||
@@ -99,16 +100,10 @@ pub async fn upload_attachment(
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result = response
|
||||
.json::<UploadAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(format!("Upload failed with status: {}", response.status()))
|
||||
}
|
||||
serde_json::from_str::<UploadAttachmentResponse>(&body)
|
||||
.map_err(|e| format!("Failed to parse upload response: {}", e))
|
||||
}
|
||||
|
||||
#[command]
|
||||
@@ -119,33 +114,30 @@ pub async fn get_attachment(
|
||||
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?;
|
||||
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params))
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<GetAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
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?;
|
||||
let response = HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Request error: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<DeleteAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.result
|
||||
.eq("deleted")
|
||||
.then_some(true)
|
||||
.ok_or("Delete operation was not successful".to_string())
|
||||
} else {
|
||||
Err(format!("Delete failed with status: {}", response.status()))
|
||||
}
|
||||
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::server::http_client::HttpClient;
|
||||
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 reqwest::StatusCode;
|
||||
use std::collections::HashMap;
|
||||
use crate::server::servers::{
|
||||
get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server,
|
||||
try_register_server_to_search_source,
|
||||
};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn request_access_token_url(request_id: &str) -> String {
|
||||
// Remove the endpoint part and keep just the path for the request
|
||||
format!("/auth/request_access_token?request_id={}", request_id)
|
||||
@@ -21,71 +22,30 @@ pub async fn handle_sso_callback<R: Runtime>(
|
||||
// Retrieve the server details using the server ID
|
||||
let server = get_server_by_id(&server_id);
|
||||
|
||||
let expire_in = 3600; // TODO, need to update to actual expire_in value
|
||||
if let Some(mut server) = server {
|
||||
// Prepare the URL for requesting the access token (endpoint is base URL, path is relative)
|
||||
// save_access_token(server_id.clone(), ServerAccessToken::new(server_id.clone(), code.clone(), 60 * 15));
|
||||
let path = request_access_token_url(&request_id);
|
||||
// Save the access token for the server
|
||||
let access_token = ServerAccessToken::new(server_id.clone(), code.clone(), expire_in);
|
||||
// dbg!(&server_id, &request_id, &code, &token);
|
||||
save_access_token(server_id.clone(), access_token);
|
||||
persist_servers_token(&app_handle)?;
|
||||
|
||||
// Send the request for the access token using the util::http::HttpClient::get method
|
||||
let mut header = HashMap::new();
|
||||
header.insert("Authorization".to_string(), format!("Bearer {}", code).to_string());
|
||||
let response = HttpClient::advanced_post(&server_id, &path, Some(header), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
// Check if the response has a valid content length
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
// Deserialize the response body to get the access token
|
||||
let token_result: Result<RequestAccessTokenResponse, _> = response.json().await;
|
||||
// Update the server's profile using the util::http::HttpClient::get method
|
||||
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
|
||||
dbg!(&profile);
|
||||
|
||||
match token_result {
|
||||
Ok(token) => {
|
||||
// Save the access token for the server
|
||||
let access_token = ServerAccessToken::new(
|
||||
server_id.clone(),
|
||||
token.access_token.clone(),
|
||||
token.expire_in,
|
||||
);
|
||||
// dbg!(&server_id, &request_id, &code, &token);
|
||||
save_access_token(server_id.clone(), access_token);
|
||||
persist_servers_token(&app_handle)?;
|
||||
|
||||
// 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())
|
||||
match profile {
|
||||
Ok(p) => {
|
||||
server.profile = Some(p);
|
||||
server.available = true;
|
||||
save_server(&server);
|
||||
persist_servers(&app_handle).await?;
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
"Request failed with status: {}, URL: {}, Code: {}, Response: {:?}",
|
||||
response.status(),
|
||||
path,
|
||||
code,
|
||||
response
|
||||
))
|
||||
Err(e) => Err(format!("Failed to get user profile: {}", e)),
|
||||
}
|
||||
} else {
|
||||
Err(format!(
|
||||
@@ -93,4 +53,4 @@ pub async fn handle_sso_callback<R: Runtime>(
|
||||
server_id, request_id, code
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct GetDatasourcesByServerOptions {
|
||||
pub from: Option<u32>,
|
||||
pub size: Option<u32>,
|
||||
pub query: Option<String>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref DATASOURCE_CACHE: Arc<RwLock<HashMap<String, HashMap<String, DataSource>>>> =
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
@@ -25,7 +32,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
||||
#[allow(dead_code)]
|
||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
let server_cache = cache.get(server_id)?; // Get the server's cache
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
@@ -41,7 +48,7 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
|
||||
// dbg!("fetch datasources for server: {}", &server.id);
|
||||
|
||||
// Attempt to get datasources by server, and continue even if it fails
|
||||
let connectors = match get_datasources_by_server(server.id.as_str()).await {
|
||||
let connectors = match datasource_search(server.id.as_str(), None).await {
|
||||
Ok(connectors) => {
|
||||
// Process connectors only after fetching them
|
||||
let connectors_map: HashMap<String, DataSource> = connectors
|
||||
@@ -83,13 +90,48 @@ pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, String> {
|
||||
pub async fn datasource_search(
|
||||
id: &str,
|
||||
options: Option<GetDatasourcesByServerOptions>,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
let from = options.as_ref().and_then(|opt| opt.from).unwrap_or(0);
|
||||
let size = options.as_ref().and_then(|opt| opt.size).unwrap_or(10000);
|
||||
let query = options
|
||||
.and_then(|opt| opt.query)
|
||||
.unwrap_or(String::default());
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
"from": from,
|
||||
"size": size,
|
||||
});
|
||||
|
||||
if !query.is_empty() {
|
||||
body["query"] = serde_json::json!({
|
||||
"bool": {
|
||||
"must": [{
|
||||
"query_string": {
|
||||
"fields": ["combined_fulltext"],
|
||||
"query": query,
|
||||
"fuzziness": "AUTO",
|
||||
"fuzzy_prefix_length": 2,
|
||||
"fuzzy_max_expansions": 10,
|
||||
"fuzzy_transpositions": true,
|
||||
"allow_leading_wildcard": false
|
||||
}
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
let resp = HttpClient::get(id, "/datasource/_search", None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
format!("Error fetching datasource: {}", e)
|
||||
})?;
|
||||
let resp = HttpClient::post(
|
||||
id,
|
||||
"/datasource/_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 datasources: Vec<DataSource> = parse_search_results(resp).await.map_err(|e| {
|
||||
@@ -102,3 +144,59 @@ pub async fn get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, Stri
|
||||
|
||||
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,10 +1,10 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use http::HeaderName;
|
||||
use http::{HeaderName, HeaderValue};
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::{Client, Method, RequestBuilder};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
||||
@@ -31,17 +31,17 @@ impl HttpClient {
|
||||
pub async fn send_raw_request(
|
||||
method: Method,
|
||||
url: &str,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
query_params: Option<HashMap<String, JsonValue>>,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let request_builder =
|
||||
Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {}", e))?;
|
||||
let response = request_builder.send().await.map_err(|e| {
|
||||
dbg!("Failed to send request: {}", &e);
|
||||
format!("Failed to send request: {}", e)
|
||||
})?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ impl HttpClient {
|
||||
method: Method,
|
||||
url: &str,
|
||||
headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> RequestBuilder {
|
||||
let client = HTTP_CLIENT.lock().await; // Acquire the lock on HTTP_CLIENT
|
||||
@@ -60,17 +60,48 @@ impl HttpClient {
|
||||
if let Some(h) = headers {
|
||||
let mut req_headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in h.into_iter() {
|
||||
let _ = req_headers.insert(
|
||||
HeaderName::from_bytes(key.as_bytes()).unwrap(),
|
||||
reqwest::header::HeaderValue::from_str(&value).unwrap(),
|
||||
);
|
||||
match (
|
||||
HeaderName::from_bytes(key.as_bytes()),
|
||||
HeaderValue::from_str(value.trim()),
|
||||
) {
|
||||
(Ok(name), Ok(val)) => {
|
||||
req_headers.insert(name, val);
|
||||
}
|
||||
(Err(e), _) => {
|
||||
eprintln!("Invalid header name: {:?}, error: {}", key, e);
|
||||
}
|
||||
(_, Err(e)) => {
|
||||
eprintln!(
|
||||
"Invalid header value for {}: {:?}, error: {}",
|
||||
key, value, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
request_builder = request_builder.headers(req_headers);
|
||||
}
|
||||
|
||||
if let Some(query) = query_params {
|
||||
// Convert only supported value types into strings
|
||||
let query: HashMap<String, String> = query
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| {
|
||||
match v {
|
||||
JsonValue::String(s) => Some((k, s)),
|
||||
JsonValue::Number(n) => Some((k, n.to_string())),
|
||||
JsonValue::Bool(b) => Some((k, b.to_string())),
|
||||
_ => {
|
||||
dbg!(
|
||||
"Unsupported query parameter type. Only strings, numbers, and booleans are supported.",k,v,
|
||||
);
|
||||
None
|
||||
} // skip arrays, objects, nulls
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
request_builder = request_builder.query(&query);
|
||||
}
|
||||
|
||||
// Add body if present
|
||||
if let Some(b) = body {
|
||||
request_builder = request_builder.body(b);
|
||||
@@ -84,7 +115,7 @@ impl HttpClient {
|
||||
method: Method,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>,
|
||||
query_params: Option<HashMap<String, JsonValue>>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
// Fetch the server using the server_id
|
||||
@@ -123,7 +154,7 @@ impl HttpClient {
|
||||
pub async fn get(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
|
||||
}
|
||||
@@ -132,7 +163,7 @@ impl HttpClient {
|
||||
pub async fn post(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::POST, path, None, query_params, body).await
|
||||
@@ -142,7 +173,7 @@ impl HttpClient {
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(
|
||||
@@ -153,7 +184,7 @@ impl HttpClient {
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for PUT requests
|
||||
@@ -162,7 +193,7 @@ impl HttpClient {
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(
|
||||
@@ -173,7 +204,7 @@ impl HttpClient {
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for DELETE requests
|
||||
@@ -182,7 +213,7 @@ impl HttpClient {
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
query_params: Option<HashMap<String, JsonValue>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
@@ -192,6 +223,6 @@ impl HttpClient {
|
||||
query_params,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ pub mod http_client;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod servers;
|
||||
pub mod system_settings;
|
||||
pub mod transcription;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::profile::UserProfile;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use tauri::{AppHandle, Runtime};
|
||||
@@ -12,14 +13,16 @@ pub async fn get_user_profiles<R: Runtime>(
|
||||
.await
|
||||
.map_err(|e| format!("Error fetching profile: {}", e))?;
|
||||
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let profile: UserProfile = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
return Ok(profile);
|
||||
}
|
||||
// Use get_response_body_text to extract the body content
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {}", e))?;
|
||||
|
||||
// Check if the response body is not empty before deserializing
|
||||
if !response_body.is_empty() {
|
||||
let profile: UserProfile = serde_json::from_str(&response_body)
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
return Ok(profile);
|
||||
}
|
||||
|
||||
Err("Profile not found or empty response".to_string())
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use crate::common::document::Document;
|
||||
use crate::common::search::{
|
||||
parse_search_response, QueryHits, QueryResponse, QuerySource, SearchQuery,
|
||||
};
|
||||
use crate::common::error::SearchError;
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::search::{QueryHits, QueryResponse, QuerySource, SearchQuery, SearchResponse};
|
||||
use crate::common::server::Server;
|
||||
use crate::common::traits::{SearchError, SearchSource};
|
||||
use crate::common::traits::SearchSource;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::servers::get_server_token;
|
||||
use async_trait::async_trait;
|
||||
@@ -46,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)
|
||||
}
|
||||
|
||||
@@ -126,58 +126,42 @@ impl SearchSource for CocoSearchSource {
|
||||
}
|
||||
}
|
||||
|
||||
// Directly return Result<QueryResponse, SearchError> instead of Future
|
||||
async fn search(&self, query: SearchQuery) -> Result<QueryResponse, SearchError> {
|
||||
let _server_id = self.server.id.clone();
|
||||
let _server_name = self.server.name.clone();
|
||||
let request_builder = self.build_request_from_query(&query).await.unwrap();
|
||||
// Build the request from the provided query
|
||||
let request_builder = self
|
||||
.build_request_from_query(&query)
|
||||
.await
|
||||
.map_err(|e| SearchError::InternalError(e.to_string()))?;
|
||||
|
||||
// Send the HTTP request asynchronously
|
||||
let response = request_builder.send().await;
|
||||
// Send the HTTP request and handle errors
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SearchError::HttpError(format!("Failed to send search request: {}", e)))?;
|
||||
|
||||
match response {
|
||||
Ok(response) => {
|
||||
let status_code = response.status().as_u16();
|
||||
// Use the helper function to parse the response body
|
||||
let response_body = get_response_body_text(response)
|
||||
.await
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to read response body: {}", e)))?;
|
||||
|
||||
if status_code >= 200 && status_code < 400 {
|
||||
// Parse the response only if the status code is successful
|
||||
match parse_search_response(response).await {
|
||||
Ok(response) => {
|
||||
let total_hits = response.hits.total.value as usize;
|
||||
let hits: Vec<(Document, f64)> = response
|
||||
.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();
|
||||
// Parse the search response from the body text
|
||||
let parsed: SearchResponse<Document> = serde_json::from_str(&response_body)
|
||||
.map_err(|e| SearchError::ParseError(format!("Failed to parse search response: {}", e)))?;
|
||||
|
||||
// Return the QueryResponse with hits and total hits
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
// Parse error when response parsing fails
|
||||
Err(SearchError::ParseError(err.to_string()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle unsuccessful HTTP status codes (e.g., 4xx, 5xx)
|
||||
Err(SearchError::HttpError(format!(
|
||||
"Request failed with status code: {}",
|
||||
status_code
|
||||
)))
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Handle error from the request itself
|
||||
Err(SearchError::HttpError(err.to_string()))
|
||||
}
|
||||
}
|
||||
// Process the parsed response
|
||||
let total_hits = parsed.hits.total.value as usize;
|
||||
let hits: Vec<(Document, f64)> = parsed
|
||||
.hits
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| (hit._source, hit._score.unwrap_or(0.0))) // Default _score to 0.0 if None
|
||||
.collect();
|
||||
|
||||
// Return the final result
|
||||
Ok(QueryResponse {
|
||||
source: self.get_type(),
|
||||
hits,
|
||||
total_hits,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::server::{AuthProvider, Provider, Server, ServerAccessToken, Sso, Version};
|
||||
use crate::server::connector::fetch_connectors_by_server;
|
||||
use crate::server::datasource::get_datasources_by_server;
|
||||
use crate::server::datasource::datasource_search;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::COCO_TAURI_STORE;
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::{Client, Method, StatusCode};
|
||||
use reqwest::{Client, Method};
|
||||
use serde_json::from_value;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
@@ -298,61 +299,57 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
id: String,
|
||||
) -> Result<Server, String> {
|
||||
// Retrieve the server from the cache
|
||||
let server = {
|
||||
let cached_server = {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.get(&id).cloned()
|
||||
};
|
||||
|
||||
if let Some(server) = server {
|
||||
let is_enabled = server.enabled;
|
||||
let is_builtin = server.builtin;
|
||||
let profile = server.profile;
|
||||
let server = match cached_server {
|
||||
Some(server) => server,
|
||||
None => return Err("Server not found.".into()),
|
||||
};
|
||||
|
||||
// Use the HttpClient to send the request
|
||||
let response = HttpClient::get(&id, "/provider/_info", None) // Assuming "/provider-info" is the endpoint
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
// Preserve important local state
|
||||
let is_enabled = server.enabled;
|
||||
let is_builtin = server.builtin;
|
||||
let profile = server.profile;
|
||||
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
server.id = id.clone();
|
||||
server.builtin = is_builtin;
|
||||
server.enabled = is_enabled;
|
||||
server.available = true;
|
||||
server.profile = profile;
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
save_server(&server);
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
.expect("Failed to persist coco servers.");
|
||||
// Send request to fetch updated server info
|
||||
let response = HttpClient::get(&id, "/provider/_info", None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to contact the server: {}", e))?;
|
||||
|
||||
//refresh connectors and datasources
|
||||
let _ = fetch_connectors_by_server(&id).await;
|
||||
|
||||
let _ = get_datasources_by_server(&id).await;
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
|
||||
}
|
||||
} else {
|
||||
Err("Received empty response body.".to_string())
|
||||
}
|
||||
} else {
|
||||
mark_server_as_offline(id.as_str()).await;
|
||||
Err("Could not determine the content length.".to_string())
|
||||
}
|
||||
} else {
|
||||
mark_server_as_offline(id.as_str()).await;
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
} else {
|
||||
Err("Server not found.".to_string())
|
||||
if !response.status().is_success() {
|
||||
mark_server_as_offline(&id).await;
|
||||
return Err(format!("Request failed with status: {}", response.status()));
|
||||
}
|
||||
|
||||
// Get body text via helper
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
// Deserialize server
|
||||
let mut updated_server: Server = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||
|
||||
// Restore local state
|
||||
updated_server.id = id.clone();
|
||||
updated_server.builtin = is_builtin;
|
||||
updated_server.enabled = is_enabled;
|
||||
updated_server.available = true;
|
||||
updated_server.profile = profile;
|
||||
trim_endpoint_last_forward_slash(&mut updated_server);
|
||||
|
||||
// Save and persist
|
||||
save_server(&updated_server);
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist servers: {}", e))?;
|
||||
|
||||
// Refresh connectors and datasources (best effort)
|
||||
let _ = fetch_connectors_by_server(&id).await;
|
||||
let _ = datasource_search(&id, None).await;
|
||||
|
||||
Ok(updated_server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -362,12 +359,10 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
) -> Result<Server, String> {
|
||||
load_or_insert_default_server(&app_handle)
|
||||
.await
|
||||
.expect("Failed to load default servers");
|
||||
.map_err(|e| format!("Failed to load default servers: {}", e))?;
|
||||
|
||||
// Remove the trailing '/' from the endpoint to ensure correct URL construction
|
||||
let endpoint = endpoint.trim_end_matches('/');
|
||||
|
||||
// Check if the server with this endpoint already exists
|
||||
if check_endpoint_exists(endpoint) {
|
||||
dbg!(format!(
|
||||
"This Coco server has already been registered: {:?}",
|
||||
@@ -376,59 +371,37 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
return Err("This Coco server has already been registered.".into());
|
||||
}
|
||||
|
||||
let url = provider_info_url(&endpoint);
|
||||
|
||||
// Use the HttpClient to fetch provider information
|
||||
let url = provider_info_url(endpoint);
|
||||
let response = HttpClient::send_raw_request(Method::GET, url.as_str(), None, None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request to the server: {}", e))?;
|
||||
|
||||
dbg!(format!("Get provider info response: {:?}", &response));
|
||||
|
||||
// Check if the response status is OK (200)
|
||||
if response.status() == StatusCode::OK {
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > 0 {
|
||||
let new_coco_server: Result<Server, _> = response.json().await;
|
||||
let body = get_response_body_text(response).await?;
|
||||
|
||||
match new_coco_server {
|
||||
Ok(mut server) => {
|
||||
// Perform necessary checks and adjustments on the server data
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
let mut server: Server = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("Failed to deserialize the response: {}", e))?;
|
||||
|
||||
if server.id.is_empty() {
|
||||
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
|
||||
}
|
||||
trim_endpoint_last_forward_slash(&mut server);
|
||||
|
||||
if server.name.is_empty() {
|
||||
server.name = "Coco Cloud".to_string();
|
||||
}
|
||||
|
||||
// Save the new server to the cache
|
||||
save_server(&server);
|
||||
|
||||
// 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.id.is_empty() {
|
||||
server.id = pizza_common::utils::uuid::Uuid::new().to_string();
|
||||
}
|
||||
|
||||
if server.name.is_empty() {
|
||||
server.name = "Coco Server".to_string();
|
||||
}
|
||||
|
||||
save_server(&server);
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist Coco servers: {}", e))?;
|
||||
|
||||
dbg!(format!("Successfully registered server: {:?}", &endpoint));
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
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())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::common::http::get_response_body_text;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -19,23 +20,24 @@ pub async fn transcription(
|
||||
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?;
|
||||
.await
|
||||
.map_err(|e| format!("Error sending transcription request: {}", e))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<TranscriptionResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Transcription failed with status: {}",
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
// 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,9 +1,6 @@
|
||||
//credits to: https://github.com/ayangweb/ayangweb-EcoPaste/blob/169323dbe6365ffe4abb64d867439ed2ea84c6d1/src-tauri/src/core/setup/mac.rs
|
||||
use tauri::{ActivationPolicy, App, Emitter, EventTarget, WebviewWindow};
|
||||
use tauri_nspanel::{
|
||||
cocoa::appkit::{NSMainMenuWindowLevel, NSWindowCollectionBehavior},
|
||||
panel_delegate, WebviewWindowExt,
|
||||
};
|
||||
use tauri_nspanel::{cocoa::appkit::NSWindowCollectionBehavior, panel_delegate, WebviewWindowExt};
|
||||
|
||||
use crate::common::MAIN_WINDOW_LABEL;
|
||||
|
||||
@@ -22,7 +19,7 @@ pub fn platform(app: &mut App, main_window: WebviewWindow, _settings_window: Web
|
||||
let panel = main_window.to_panel().unwrap();
|
||||
|
||||
// Make the window above the dock
|
||||
panel.set_level(NSMainMenuWindowLevel + 1);
|
||||
panel.set_level(20);
|
||||
|
||||
// Do not steal focus from other windows
|
||||
panel.set_style_mask(NSWindowStyleMaskNonActivatingPanel);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
use tauri::{App, WebviewWindow};
|
||||
|
||||
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {}
|
||||
pub fn platform(_app: &mut App, _main_window: WebviewWindow, _settings_window: WebviewWindow) {}
|
||||
|
||||
@@ -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,7 +31,7 @@
|
||||
"visible": false,
|
||||
"windowEffects": {
|
||||
"effects": [],
|
||||
"radius": 12
|
||||
"radius": 6
|
||||
},
|
||||
"visibleOnAllWorkspaces": true,
|
||||
"alwaysOnTop": true
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface UploadAttachmentPayload {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
interface UploadAttachmentResponse {
|
||||
acknowledged: boolean;
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
type GetAttachmentPayload = Omit<UploadAttachmentPayload, "filePaths">;
|
||||
|
||||
export interface AttachmentHit {
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
session: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetAttachmentResponse {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
_shards: {
|
||||
total: number;
|
||||
successful: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
relation: string;
|
||||
};
|
||||
max_score: number;
|
||||
hits: AttachmentHit[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteAttachmentPayload {
|
||||
serverId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const uploadAttachment = async (payload: UploadAttachmentPayload) => {
|
||||
const response = await invoke<UploadAttachmentResponse>("upload_attachment", {
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (response?.acknowledged) {
|
||||
return response.attachments;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAttachment = (payload: GetAttachmentPayload) => {
|
||||
return invoke<GetAttachmentResponse>("get_attachment", { ...payload });
|
||||
};
|
||||
|
||||
export const deleteAttachment = (payload: DeleteAttachmentPayload) => {
|
||||
return invoke<boolean>("delete_attachment", { ...payload });
|
||||
};
|
||||
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]);
|
||||
});
|
||||
});
|
||||
};
|
||||
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;
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface TranscriptionPayload {
|
||||
serverId: string;
|
||||
audioType: string;
|
||||
audioContent: string;
|
||||
}
|
||||
|
||||
interface TranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const transcription = (payload: TranscriptionPayload) => {
|
||||
return invoke<TranscriptionResponse>("transcription", { ...payload });
|
||||
};
|
||||
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 |
@@ -1,37 +1,86 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { ServerTokenResponse, Server, Connector, DataSource, GetResponse } from "@/types/commands"
|
||||
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 invoke(`get_server_token`, { id });
|
||||
return invokeWithErrorHandler(`get_server_token`, { id });
|
||||
}
|
||||
|
||||
export function list_coco_servers(): Promise<Server[]> {
|
||||
return invoke(`list_coco_servers`);
|
||||
return invokeWithErrorHandler(`list_coco_servers`);
|
||||
}
|
||||
|
||||
export function add_coco_server(endpoint: string): Promise<Server> {
|
||||
return invoke(`add_coco_server`, { endpoint });
|
||||
return invokeWithErrorHandler(`add_coco_server`, { endpoint });
|
||||
}
|
||||
|
||||
export function enable_server(id: string): Promise<void> {
|
||||
return invoke(`enable_server`, { id });
|
||||
return invokeWithErrorHandler(`enable_server`, { id });
|
||||
}
|
||||
|
||||
export function disable_server(id: string): Promise<void> {
|
||||
return invoke(`disable_server`, { id });
|
||||
return invokeWithErrorHandler(`disable_server`, { id });
|
||||
}
|
||||
|
||||
export function remove_coco_server(id: string): Promise<void> {
|
||||
return invoke(`remove_coco_server`, { id });
|
||||
return invokeWithErrorHandler(`remove_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function logout_coco_server(id: string): Promise<void> {
|
||||
return invoke(`logout_coco_server`, { id });
|
||||
return invokeWithErrorHandler(`logout_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function refresh_coco_server_info(id: string): Promise<Server> {
|
||||
return invoke(`refresh_coco_server_info`, { id });
|
||||
return invokeWithErrorHandler(`refresh_coco_server_info`, { id });
|
||||
}
|
||||
|
||||
export function handle_sso_callback({
|
||||
@@ -43,7 +92,7 @@ export function handle_sso_callback({
|
||||
requestId: string;
|
||||
code: string;
|
||||
}): Promise<void> {
|
||||
return invoke(`handle_sso_callback`, {
|
||||
return invokeWithErrorHandler(`handle_sso_callback`, {
|
||||
serverId,
|
||||
requestId,
|
||||
code,
|
||||
@@ -51,34 +100,41 @@ export function handle_sso_callback({
|
||||
}
|
||||
|
||||
export function get_connectors_by_server(id: string): Promise<Connector[]> {
|
||||
return invoke(`get_connectors_by_server`, { id });
|
||||
return invokeWithErrorHandler(`get_connectors_by_server`, { id });
|
||||
}
|
||||
|
||||
export function get_datasources_by_server(id: string): Promise<DataSource[]> {
|
||||
return invoke(`get_datasources_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 invoke(`connect_to_server`, { id, clientId });
|
||||
return invokeWithErrorHandler(`connect_to_server`, { id, clientId });
|
||||
}
|
||||
|
||||
export function disconnect(clientId: string): Promise<void> {
|
||||
return invoke(`disconnect`, { clientId });
|
||||
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 invoke(`chat_history`, {
|
||||
return invokeWithErrorHandler(`chat_history`, {
|
||||
serverId,
|
||||
from,
|
||||
size,
|
||||
query,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +149,7 @@ export function session_chat_history({
|
||||
from?: number;
|
||||
size?: number;
|
||||
}): Promise<string> {
|
||||
return invoke(`session_chat_history`, {
|
||||
return invokeWithErrorHandler(`session_chat_history`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
from,
|
||||
@@ -108,7 +164,7 @@ export function close_session_chat({
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`close_session_chat`, {
|
||||
return invokeWithErrorHandler(`close_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
@@ -121,7 +177,7 @@ export function open_session_chat({
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`open_session_chat`, {
|
||||
return invokeWithErrorHandler(`open_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
@@ -134,7 +190,7 @@ export function cancel_session_chat({
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`cancel_session_chat`, {
|
||||
return invokeWithErrorHandler(`cancel_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
@@ -151,7 +207,7 @@ export function new_chat({
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<GetResponse> {
|
||||
return invoke(`new_chat`, {
|
||||
return invokeWithErrorHandler(`new_chat`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
@@ -172,11 +228,75 @@ export function send_message({
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<string> {
|
||||
return invoke(`send_message`, {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
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 { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||
import useWebSocket from "@/hooks/useWebSocket";
|
||||
@@ -21,11 +20,13 @@ import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatContent } from "./ChatContent";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import type { Chat } from "./types";
|
||||
import PrevSuggestion from "@/components/ChatMessage/PrevSuggestion";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
|
||||
interface ChatAIProps {
|
||||
isTransitioned: boolean;
|
||||
isSearchActive?: boolean;
|
||||
isDeepThinkActive?: boolean;
|
||||
isMCPActive?: boolean;
|
||||
activeChatProp?: Chat;
|
||||
changeInput?: (val: string) => void;
|
||||
setIsSidebarOpen?: (value: boolean) => void;
|
||||
@@ -33,6 +34,8 @@ interface ChatAIProps {
|
||||
clearChatPage?: () => void;
|
||||
isChatPage?: boolean;
|
||||
getFileUrl: (path: string) => string;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
@@ -46,21 +49,21 @@ const ChatAI = memo(
|
||||
forwardRef<ChatAIRef, ChatAIProps>(
|
||||
(
|
||||
{
|
||||
isTransitioned,
|
||||
changeInput,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
isMCPActive,
|
||||
activeChatProp,
|
||||
setIsSidebarOpen,
|
||||
isSidebarOpen = false,
|
||||
clearChatPage,
|
||||
isChatPage = false,
|
||||
getFileUrl,
|
||||
showChatHistory,
|
||||
assistantIDs,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
if (!isTransitioned) return null;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
init: init,
|
||||
cancelChat: () => cancelChat(activeChat),
|
||||
@@ -72,6 +75,11 @@ const ChatAI = memo(
|
||||
useChatStore();
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const visibleStartPage = useConnectStore((state) => {
|
||||
return state.visibleStartPage;
|
||||
});
|
||||
|
||||
const addError = useAppStore.getState().addError;
|
||||
|
||||
const [activeChat, setActiveChat] = useState<Chat>();
|
||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||
@@ -81,7 +89,6 @@ const ChatAI = memo(
|
||||
|
||||
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
||||
|
||||
useEffect(() => {
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
@@ -89,7 +96,7 @@ const ChatAI = memo(
|
||||
|
||||
const [Question, setQuestion] = useState<string>("");
|
||||
|
||||
const [websocketSessionId, setWebsocketSessionId] = useState('');
|
||||
const [websocketSessionId, setWebsocketSessionId] = useState("");
|
||||
|
||||
const onWebsocketSessionId = useCallback((sessionId: string) => {
|
||||
setWebsocketSessionId(sessionId);
|
||||
@@ -98,6 +105,7 @@ const ChatAI = memo(
|
||||
const {
|
||||
data: {
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
@@ -110,6 +118,7 @@ const ChatAI = memo(
|
||||
|
||||
const [loadingStep, setLoadingStep] = useState<Record<string, boolean>>({
|
||||
query_intent: false,
|
||||
tools: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
@@ -119,16 +128,15 @@ const ChatAI = memo(
|
||||
|
||||
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
|
||||
|
||||
const clientId = isChatPage ? "standalone" : "popup"
|
||||
const { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg } =
|
||||
useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
const clientId = isChatPage ? "standalone" : "popup";
|
||||
const { reconnect, disconnectWS, updateDealMsg } = useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
|
||||
const {
|
||||
chatClose,
|
||||
@@ -139,20 +147,24 @@ const ChatAI = memo(
|
||||
openSessionChat,
|
||||
getChatHistory,
|
||||
createChatWindow,
|
||||
handleSearch,
|
||||
handleRename,
|
||||
handleDelete,
|
||||
} = useChatActions(
|
||||
currentService?.id,
|
||||
setActiveChat,
|
||||
setCurChatEnd,
|
||||
setErrorShow,
|
||||
setTimedoutShow,
|
||||
clearAllChunkData,
|
||||
setQuestion,
|
||||
curIdRef,
|
||||
setChats,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
sourceDataIds,
|
||||
isMCPActive,
|
||||
changeInput,
|
||||
websocketSessionId
|
||||
websocketSessionId,
|
||||
showChatHistory,
|
||||
);
|
||||
|
||||
const { dealMsg, messageTimeoutRef } = useMessageHandler(
|
||||
@@ -161,7 +173,7 @@ const ChatAI = memo(
|
||||
setTimedoutShow,
|
||||
(chat) => cancelChat(chat || activeChat),
|
||||
setLoadingStep,
|
||||
handlers,
|
||||
handlers
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -174,7 +186,6 @@ const ChatAI = memo(
|
||||
const clearChat = useCallback(() => {
|
||||
console.log("clearChat");
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
@@ -184,21 +195,38 @@ const ChatAI = memo(
|
||||
chatClose,
|
||||
clearChatPage,
|
||||
setCurChatEnd,
|
||||
setErrorShow,
|
||||
setTimedoutShow,
|
||||
]);
|
||||
|
||||
const init = useCallback(
|
||||
(value: string) => {
|
||||
if (!isLogin) return;
|
||||
if (!curChatEnd) return;
|
||||
if (!activeChat?._id) {
|
||||
createNewChat(value, activeChat, websocketSessionId);
|
||||
} else {
|
||||
handleSendMessage(value, activeChat, websocketSessionId);
|
||||
async (value: string) => {
|
||||
try {
|
||||
console.log("init", isLogin, curChatEnd, activeChat?._id);
|
||||
if (!isLogin) {
|
||||
addError("Please login to continue chatting");
|
||||
return;
|
||||
}
|
||||
if (!curChatEnd) {
|
||||
addError("Please wait for the current conversation to complete");
|
||||
return;
|
||||
}
|
||||
if (!activeChat?._id) {
|
||||
await createNewChat(value, activeChat, websocketSessionId);
|
||||
} else {
|
||||
await handleSendMessage(value, activeChat, websocketSessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize chat:", error);
|
||||
}
|
||||
},
|
||||
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage, websocketSessionId]
|
||||
[
|
||||
isLogin,
|
||||
curChatEnd,
|
||||
activeChat,
|
||||
createNewChat,
|
||||
handleSendMessage,
|
||||
websocketSessionId,
|
||||
]
|
||||
);
|
||||
|
||||
const { createWin } = useWindows();
|
||||
@@ -207,21 +235,23 @@ const ChatAI = memo(
|
||||
}, [createChatWindow, createWin]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurChatEnd(true);
|
||||
return () => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
disconnectWS();
|
||||
Promise.resolve().then(() => {
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
disconnectWS();
|
||||
});
|
||||
};
|
||||
}, [chatClose, setCurChatEnd]);
|
||||
|
||||
const onSelectChat = useCallback(
|
||||
async (chat: Chat) => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
clearAllChunkData();
|
||||
await cancelChat(activeChat);
|
||||
await chatClose(activeChat);
|
||||
@@ -240,17 +270,24 @@ const ChatAI = memo(
|
||||
]
|
||||
);
|
||||
|
||||
const deleteChat = useCallback((chatId: string) => {
|
||||
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
||||
if (activeChat?._id === chatId) {
|
||||
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
||||
if (remainingChats.length > 0) {
|
||||
setActiveChat(remainingChats[0]);
|
||||
} else {
|
||||
init("");
|
||||
const deleteChat = useCallback(
|
||||
(chatId: string) => {
|
||||
handleDelete(chatId);
|
||||
|
||||
setChats((prev) => prev.filter((chat) => chat._id !== chatId));
|
||||
|
||||
if (activeChat?._id === chatId) {
|
||||
const remainingChats = chats.filter((chat) => chat._id !== chatId);
|
||||
|
||||
if (remainingChats.length > 0) {
|
||||
setActiveChat(remainingChats[0]);
|
||||
} else {
|
||||
init("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [activeChat, chats, init, setActiveChat]);
|
||||
},
|
||||
[activeChat, chats, init, setActiveChat]
|
||||
);
|
||||
|
||||
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
||||
const sidebar = document.querySelector("[data-sidebar]");
|
||||
@@ -274,40 +311,61 @@ const ChatAI = memo(
|
||||
};
|
||||
}, [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(() => {
|
||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||
!isSidebarOpenChat && fetchChatHistory();
|
||||
}, [isSidebarOpenChat, setIsSidebarOpen, fetchChatHistory]);
|
||||
!isSidebarOpenChat && getChatHistory();
|
||||
}, [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 (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`h-full flex flex-col rounded-xl overflow-hidden`}
|
||||
className={`h-full flex flex-col rounded-md relative`}
|
||||
>
|
||||
{!setIsSidebarOpen && (
|
||||
{showChatHistory && !setIsSidebarOpen && (
|
||||
<ChatSidebar
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={clearChat}
|
||||
// onNewChat={clearChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
fetchChatHistory={getChatHistory}
|
||||
onSearch={handleSearch}
|
||||
onRename={renameChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -319,13 +377,17 @@ const ChatAI = memo(
|
||||
activeChat={activeChat}
|
||||
reconnect={reconnect}
|
||||
isChatPage={isChatPage}
|
||||
setIsLogin={setIsLoginChat}
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
showChatHistory={showChatHistory}
|
||||
assistantIDs={assistantIDs}
|
||||
/>
|
||||
{isLogin ? (
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
curChatEnd={curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
@@ -333,14 +395,19 @@ const ChatAI = memo(
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
errorShow={errorShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) => handleSendMessage(value, activeChat)}
|
||||
handleSendMessage={(value) =>
|
||||
handleSendMessage(value, activeChat)
|
||||
}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
) : (
|
||||
<ConnectPrompt />
|
||||
)}
|
||||
|
||||
{!activeChat?._id && !visibleStartPage && (
|
||||
<PrevSuggestion sendMessage={init} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,13 +7,16 @@ import FileList from "@/components/Assistant/FileList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import type { Chat, IChunkData } from "./types";
|
||||
import SessionFile from "./SessionFile";
|
||||
// import SessionFile from "./SessionFile";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import SessionFile from "./SessionFile";
|
||||
import Splash from "./Splash";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
curChatEnd: boolean;
|
||||
query_intent?: IChunkData;
|
||||
tools?: IChunkData;
|
||||
fetch_source?: IChunkData;
|
||||
pick_source?: IChunkData;
|
||||
deep_read?: IChunkData;
|
||||
@@ -21,7 +24,6 @@ interface ChatContentProps {
|
||||
response?: IChunkData;
|
||||
loadingStep?: Record<string, boolean>;
|
||||
timedoutShow: boolean;
|
||||
errorShow: boolean;
|
||||
Question: string;
|
||||
handleSendMessage: (content: string, newChat?: Chat) => void;
|
||||
getFileUrl: (path: string) => string;
|
||||
@@ -31,6 +33,7 @@ export const ChatContent = ({
|
||||
activeChat,
|
||||
curChatEnd,
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
@@ -38,7 +41,6 @@ export const ChatContent = ({
|
||||
response,
|
||||
loadingStep,
|
||||
timedoutShow,
|
||||
errorShow,
|
||||
Question,
|
||||
handleSendMessage,
|
||||
getFileUrl,
|
||||
@@ -94,6 +96,7 @@ export const ChatContent = ({
|
||||
))}
|
||||
{(!curChatEnd ||
|
||||
query_intent ||
|
||||
tools ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
@@ -113,6 +116,7 @@ export const ChatContent = ({
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
query_intent={query_intent}
|
||||
tools={tools}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
@@ -136,21 +140,6 @@ export const ChatContent = ({
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
{errorShow ? (
|
||||
<ChatMessage
|
||||
key={"error"}
|
||||
message={{
|
||||
_id: "error",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.error"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -161,6 +150,8 @@ export const ChatContent = ({
|
||||
)}
|
||||
|
||||
{sessionId && <SessionFile sessionId={sessionId} />}
|
||||
|
||||
<Splash />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import {
|
||||
MessageSquarePlus,
|
||||
ChevronDownIcon,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from "@headlessui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import HistoryIcon from "@/icons/History";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import ServerIcon from "@/icons/Server";
|
||||
import WindowsFullIcon from "@/icons/WindowsFull";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import type { Chat } from "./types";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
import { AssistantList } from "./AssistantList";
|
||||
import { ServerList } from "./ServerList";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onCreateNewChat: () => void;
|
||||
onOpenChatAI: () => void;
|
||||
@@ -36,97 +21,41 @@ interface ChatHeaderProps {
|
||||
isSidebarOpen: boolean;
|
||||
activeChat: Chat | undefined;
|
||||
reconnect: (server?: IServer) => void;
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
isChatPage?: boolean;
|
||||
showChatHistory?: boolean;
|
||||
assistantIDs?: string[];
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
onCreateNewChat,
|
||||
onOpenChatAI,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
activeChat,
|
||||
reconnect,
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
isChatPage = false,
|
||||
showChatHistory = true,
|
||||
assistantIDs,
|
||||
}: ChatHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const historicalRecords = useShortcutsStore((state) => {
|
||||
return state.historicalRecords;
|
||||
});
|
||||
const newSession = useShortcutsStore((state) => {
|
||||
return state.newSession;
|
||||
});
|
||||
const fixedWindow = useShortcutsStore((state) => {
|
||||
return state.fixedWindow;
|
||||
});
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const fetchServers = useCallback(async (resetSelection: boolean) => {
|
||||
platformAdapter.invokeBackend("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 = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
console.log("Login or Logout:", currentService, event);
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const external = useShortcutsStore((state) => state.external);
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
@@ -139,183 +68,81 @@ export function ChatHeader({
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
className="flex items-center justify-between py-2 px-3"
|
||||
className="flex items-center justify-between py-2 px-3 select-none"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
data-sidebar-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSidebarOpen();
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<HistoryIcon />
|
||||
</button>
|
||||
|
||||
<Menu>
|
||||
<MenuButton className="flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] p-1 text-sm/6 font-semibold text-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
Coco AI
|
||||
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400" />
|
||||
</MenuButton>
|
||||
|
||||
<MenuItems
|
||||
transition
|
||||
anchor="bottom end"
|
||||
className="w-28 origin-top-right rounded-xl bg-white dark:bg-[#202126] p-1 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
|
||||
{showChatHistory && (
|
||||
<button
|
||||
data-sidebar-button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSidebarOpen();
|
||||
}}
|
||||
aria-controls={isSidebarOpen ? HISTORY_PANEL_ID : void 0}
|
||||
className="py-1 px-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<MenuItem>
|
||||
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
Coco AI
|
||||
</button>
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
|
||||
<button
|
||||
onClick={onCreateNewChat}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{activeChat?._source?.title ||
|
||||
activeChat?._source?.message ||
|
||||
activeChat?._id}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={`${isPinned ? "text-blue-500" : ""}`}
|
||||
>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</button>
|
||||
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center">
|
||||
<ServerIcon />
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Servers
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
|
||||
currentService?.id === server.id
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
||||
<img
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
AI Assistant: {server.assistantCount || 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
server.health?.status
|
||||
? `bg-[${server.health?.status}]`
|
||||
: "bg-gray-400 dark:bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-4 h-4">
|
||||
{currentService?.id === server.id && (
|
||||
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("assistant.chat.noServers")}
|
||||
</p>
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="mt-2 text-xs text-[#0287FF] hover:underline"
|
||||
>
|
||||
{t("assistant.chat.addServer")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
|
||||
{isChatPage ? null : (
|
||||
<button onClick={onOpenChatAI}>
|
||||
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
|
||||
<VisibleKey
|
||||
shortcut={historicalRecords}
|
||||
onKeyPress={setIsSidebarOpen}
|
||||
>
|
||||
<HistoryIcon className="h-4 w-4" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AssistantList assistantIDs={assistantIDs} />
|
||||
|
||||
{showChatHistory ? (
|
||||
<button
|
||||
onClick={onCreateNewChat}
|
||||
className="p-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<VisibleKey shortcut={newSession} onKeyPress={onCreateNewChat}>
|
||||
<MessageSquarePlus className="h-4 w-4 relative top-0.5" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="max-w-[calc(100%-200px)] text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||
{activeChat?._source?.title ||
|
||||
activeChat?._source?.message ||
|
||||
activeChat?._id}
|
||||
</h2>
|
||||
{isTauri ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx("inline-flex", {
|
||||
"text-blue-500": isPinned,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
|
||||
<ServerList
|
||||
isLogin={isLogin}
|
||||
setIsLogin={setIsLogin}
|
||||
reconnect={reconnect}
|
||||
onCreateNewChat={onCreateNewChat}
|
||||
/>
|
||||
|
||||
{isChatPage ? null : (
|
||||
<button className="inline-flex" onClick={onOpenChatAI}>
|
||||
<VisibleKey shortcut={external} onKeyPress={onOpenChatAI}>
|
||||
<WindowsFullIcon className="rotate-30 scale-x-[-1]" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,65 @@
|
||||
import React from "react";
|
||||
|
||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
// import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import type { Chat } from "./types";
|
||||
import HistoryList from "../Common/HistoryList";
|
||||
import { HISTORY_PANEL_ID } from "@/constants";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
chats: Chat[];
|
||||
activeChat?: Chat;
|
||||
onNewChat: () => void;
|
||||
// onNewChat: () => void;
|
||||
onSelectChat: (chat: any) => 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> = ({
|
||||
isSidebarOpen,
|
||||
chats,
|
||||
activeChat,
|
||||
onNewChat,
|
||||
// onNewChat,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
fetchChatHistory,
|
||||
onSearch,
|
||||
onRename,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-sidebar
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-[calc(100%)]"}
|
||||
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden`}
|
||||
className={`
|
||||
h-[calc(100%+90px)] absolute top-0 left-0 z-10 w-64
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden
|
||||
`}
|
||||
>
|
||||
<Sidebar
|
||||
{isSidebarOpen && (
|
||||
<HistoryList
|
||||
id={HISTORY_PANEL_ID}
|
||||
list={chats}
|
||||
active={activeChat}
|
||||
onSearch={onSearch}
|
||||
onRefresh={fetchChatHistory}
|
||||
onSelect={onSelectChat}
|
||||
onRename={onRename}
|
||||
onRemove={onDeleteChat}
|
||||
/>
|
||||
)}
|
||||
{/* <Sidebar
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={onNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/>
|
||||
/> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface FileListProps {
|
||||
sessionId: string;
|
||||
@@ -39,11 +39,14 @@ const FileList = (props: FileListProps) => {
|
||||
|
||||
if (uploaded) continue;
|
||||
|
||||
const attachmentIds = await uploadAttachment({
|
||||
serverId,
|
||||
sessionId,
|
||||
filePaths: [path],
|
||||
});
|
||||
const attachmentIds: any = await platformAdapter.commands(
|
||||
"upload_attachment",
|
||||
{
|
||||
serverId,
|
||||
sessionId,
|
||||
filePaths: [path],
|
||||
}
|
||||
);
|
||||
|
||||
if (!attachmentIds) continue;
|
||||
|
||||
@@ -59,7 +62,10 @@ const FileList = (props: FileListProps) => {
|
||||
const deleteFile = async (id: string, attachmentId: string) => {
|
||||
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
|
||||
|
||||
deleteAttachment({ serverId, id: attachmentId });
|
||||
platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id: attachmentId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
export const Greetings = () => {
|
||||
const { t } = useTranslation();
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
@@ -12,7 +14,9 @@ export const Greetings = () => {
|
||||
_id: "greetings",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.greetings"),
|
||||
message:
|
||||
currentAssistant?._source?.chat_settings?.greeting_message ||
|
||||
t("assistant.chat.greetings"),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
258
src/components/Assistant/ServerList.tsx
Normal file
258
src/components/Assistant/ServerList.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Settings, RefreshCw, Check, Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { useKeyPress } from "ahooks";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import ServerIcon from "@/icons/Server";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useAppStore, IServer } from "@/stores/appStore";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { isNil } from "lodash-es";
|
||||
|
||||
interface ServerListProps {
|
||||
isLogin: boolean;
|
||||
setIsLogin: (isLogin: boolean) => void;
|
||||
reconnect: (server?: IServer) => void;
|
||||
onCreateNewChat: () => void;
|
||||
}
|
||||
|
||||
export function ServerList({
|
||||
isLogin,
|
||||
setIsLogin,
|
||||
reconnect,
|
||||
onCreateNewChat,
|
||||
}: ServerListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const serviceList = useShortcutsStore((state) => state.serviceList);
|
||||
const setEndpoint = useAppStore((state) => state.setEndpoint);
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const serverListButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const fetchServers = useCallback(
|
||||
async (resetSelection: boolean) => {
|
||||
platformAdapter
|
||||
.commands("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
const enabledServers = (res as IServer[]).filter(
|
||||
(server) => server.enabled !== false
|
||||
);
|
||||
//console.log("list_coco_servers", enabledServers);
|
||||
setServerList(enabledServers);
|
||||
|
||||
if (resetSelection && enabledServers.length > 0) {
|
||||
const currentServiceExists = enabledServers.find(
|
||||
(server) => server.id === currentService?.id
|
||||
);
|
||||
|
||||
if (currentServiceExists) {
|
||||
switchServer(currentServiceExists);
|
||||
} else {
|
||||
switchServer(enabledServers[enabledServers.length - 1]);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri) return;
|
||||
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
console.log("Login or Logout:", currentService, event.payload);
|
||||
if (event.payload !== isLogin) {
|
||||
setIsLogin(!!event.payload);
|
||||
}
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
await fetchServers(false);
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
try {
|
||||
// Switch UI first, then switch server connection
|
||||
setCurrentService(server);
|
||||
setEndpoint(server.endpoint);
|
||||
setMessages(""); // Clear previous messages
|
||||
onCreateNewChat();
|
||||
//
|
||||
if (!server.public && !server.profile) {
|
||||
setIsLogin(false);
|
||||
return;
|
||||
}
|
||||
setIsLogin(true);
|
||||
// The Rust backend will automatically disconnect,
|
||||
// so we don't need to handle disconnection on the frontend
|
||||
// src-tauri/src/server/websocket.rs
|
||||
reconnect && reconnect(server);
|
||||
} catch (error) {
|
||||
console.error("switchServer:", error);
|
||||
}
|
||||
};
|
||||
|
||||
useKeyPress(["uparrow", "downarrow"], (_, key) => {
|
||||
const isClose = isNil(serverListButtonRef.current?.dataset["open"]);
|
||||
const length = serverList.length;
|
||||
|
||||
if (isClose || length <= 1) return;
|
||||
|
||||
const currentIndex = serverList.findIndex((server) => {
|
||||
return server.id === currentService?.id;
|
||||
});
|
||||
|
||||
let nextIndex = currentIndex;
|
||||
|
||||
if (key === "uparrow") {
|
||||
nextIndex = currentIndex > 0 ? currentIndex - 1 : length - 1;
|
||||
} else if (key === "downarrow") {
|
||||
nextIndex = currentIndex < serverList.length - 1 ? currentIndex + 1 : 0;
|
||||
}
|
||||
|
||||
switchServer(serverList[nextIndex]);
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton ref={serverListButtonRef} className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut={serviceList}
|
||||
onKeyPress={() => {
|
||||
serverListButtonRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<ServerIcon />
|
||||
</VisibleKey>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Servers
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<VisibleKey shortcut=",">
|
||||
<Settings className="h-4 w-4 text-[#0287FF]" />
|
||||
</VisibleKey>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{serverList.length > 0 ? (
|
||||
serverList.map((server) => (
|
||||
<div
|
||||
key={server.id}
|
||||
onClick={() => switchServer(server)}
|
||||
className={`w-full flex items-center justify-between gap-1 p-2 rounded-lg transition-colors whitespace-nowrap ${
|
||||
currentService?.id === server.id
|
||||
? "bg-gray-100 dark:bg-gray-800"
|
||||
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden min-w-0">
|
||||
<img
|
||||
src={server?.provider?.icon || logoImg}
|
||||
alt={server.name}
|
||||
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
|
||||
/>
|
||||
<div className="text-left flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||
{server.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||
AI Assistant: {server.assistantCount || 1}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
server.health?.status
|
||||
? `bg-[${server.health?.status}]`
|
||||
: "bg-gray-400 dark:bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="size-4 flex justify-end">
|
||||
{currentService?.id === server.id && (
|
||||
<VisibleKey
|
||||
shortcut="↓↑"
|
||||
shortcutClassName="w-6 -translate-x-4"
|
||||
>
|
||||
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Server className="w-8 h-8 text-gray-400 dark:text-gray-600 mb-2" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{t("assistant.chat.noServers")}
|
||||
</p>
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="mt-2 text-xs text-[#0287FF] hover:underline"
|
||||
>
|
||||
{t("assistant.chat.addServer")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import {
|
||||
AttachmentHit,
|
||||
deleteAttachment,
|
||||
getAttachment,
|
||||
} from "@/api/attachment";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import clsx from "clsx";
|
||||
import { filesize } from "filesize";
|
||||
import { Files, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Checkbox from "../Common/Checkbox";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
|
||||
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;
|
||||
@@ -19,6 +18,8 @@ interface SessionFileProps {
|
||||
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);
|
||||
@@ -35,14 +36,26 @@ const SessionFile = (props: SessionFileProps) => {
|
||||
}, [sessionId]);
|
||||
|
||||
const getUploadedFiles = async () => {
|
||||
const response = await getAttachment({ serverId, sessionId });
|
||||
if (isTauri) {
|
||||
const response: any = await platformAdapter.commands("get_attachment", {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
setUploadedFiles(response.hits.hits);
|
||||
setUploadedFiles(response.hits.hits);
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await deleteAttachment({ serverId, id });
|
||||
|
||||
let result;
|
||||
if (isTauri) {
|
||||
result = await platformAdapter.commands("delete_attachment", {
|
||||
serverId,
|
||||
id,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
if (!result) return;
|
||||
|
||||
getUploadedFiles();
|
||||
|
||||
160
src/components/Assistant/Splash.tsx
Normal file
160
src/components/Assistant/Splash.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CircleX, MoveRight } from "lucide-react";
|
||||
import { useMount } from "ahooks";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
|
||||
interface StartPage {
|
||||
enabled?: boolean;
|
||||
logo?: {
|
||||
light?: string;
|
||||
dark?: string;
|
||||
};
|
||||
introduction?: string;
|
||||
display_assistants?: string[];
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
app_settings?: {
|
||||
chat?: {
|
||||
start_page?: StartPage;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const Splash = () => {
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const [settings, setSettings] = useState<StartPage>();
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
const setVisibleStartPage = useConnectStore((state) => {
|
||||
return state.setVisibleStartPage;
|
||||
});
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const setCurrentAssistant = useConnectStore((state) => {
|
||||
return state.setCurrentAssistant;
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
try {
|
||||
const serverId = currentService.id;
|
||||
|
||||
let response: Response = {};
|
||||
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.invokeBackend<Response>(
|
||||
"get_system_settings",
|
||||
{
|
||||
serverId,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const [err, result] = await Get("/settings");
|
||||
|
||||
if (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
|
||||
response = result as Response;
|
||||
}
|
||||
|
||||
const settings = response?.app_settings?.chat?.start_page;
|
||||
|
||||
setVisibleStartPage(Boolean(settings?.enabled));
|
||||
|
||||
setSettings(settings);
|
||||
} catch (error) {
|
||||
addError(String(error), "error");
|
||||
}
|
||||
});
|
||||
|
||||
const settingsAssistantList = useMemo(() => {
|
||||
console.log("assistantList", assistantList);
|
||||
|
||||
return assistantList.filter((item) => {
|
||||
return settings?.display_assistants?.includes(item?._source?.id);
|
||||
});
|
||||
}, [settings, assistantList]);
|
||||
|
||||
const logo = useMemo(() => {
|
||||
const { light, dark } = settings?.logo || {};
|
||||
|
||||
if (isDark) {
|
||||
return dark || light;
|
||||
}
|
||||
|
||||
return light || dark;
|
||||
}, [settings, isDark]);
|
||||
|
||||
return (
|
||||
visibleStartPage && (
|
||||
<div className="absolute inset-0 flex flex-col items-center px-6 pt-6 text-[#333] dark:text-white select-none">
|
||||
<CircleX
|
||||
className="absolute top-3 right-3 size-4 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisibleStartPage(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<img src={logo} className="h-8" />
|
||||
|
||||
<div className="mt-3 mb-6 text-lg font-medium">
|
||||
{settings?.introduction}
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap -m-1 w-full p-0">
|
||||
{settingsAssistantList?.map((item) => {
|
||||
const { id, name, description, icon } = item._source;
|
||||
|
||||
return (
|
||||
<li key={id} className="w-1/2 p-1">
|
||||
<div
|
||||
className="group h-[74px] px-3 py-2 text-sm rounded-xl border dark:border-[#262626] bg-white dark:bg-black cursor-pointer transition hover:!border-[#0087FF]"
|
||||
onClick={() => {
|
||||
setCurrentAssistant(item);
|
||||
|
||||
setVisibleStartPage(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{icon?.startsWith("font_") ? (
|
||||
<div className="size-4 flex items-center justify-center rounded-full bg-white">
|
||||
<FontIcon name={icon} className="w-5 h-5" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="size-4 rounded-full"
|
||||
alt={name}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
|
||||
<MoveRight className="size-4 transition group-hover:text-[#0087FF]" />
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-[#999] line-clamp-2">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default Splash;
|
||||
@@ -15,6 +15,7 @@ export interface ISource {
|
||||
title?: string;
|
||||
question?: string;
|
||||
details?: any[];
|
||||
assistant_id?: string;
|
||||
}
|
||||
export interface Chat {
|
||||
_id: string;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useKeyPress, useReactive } from "ahooks";
|
||||
import { useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { Check, Loader, Mic, X } from "lucide-react";
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
@@ -9,9 +8,12 @@ import {
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
import { useWavesurfer } from "@wavesurfer/react";
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js";
|
||||
import { transcription } from "@/api/transcription";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface AudioRecordingProps {
|
||||
onChange?: (text: string) => void;
|
||||
@@ -40,12 +42,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
const recordRef = useRef<RecordPlugin>();
|
||||
const withVisibility = useAppStore((state) => state.withVisibility);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const voiceInput = useShortcutsStore((state) => state.voiceInput);
|
||||
|
||||
const { wavesurfer } = useWavesurfer({
|
||||
@@ -82,7 +78,7 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = (reader.result as string).split(",")[1];
|
||||
|
||||
const response = await transcription({
|
||||
const response: any = await platformAdapter.commands("transcription", {
|
||||
serverId: currentService.id,
|
||||
audioType: "mp3",
|
||||
audioContent: base64Audio,
|
||||
@@ -113,10 +109,6 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
}, 1000);
|
||||
}, [state.isRecording]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${voiceInput}`, () => {
|
||||
startRecording();
|
||||
});
|
||||
|
||||
const getAvailableAudioDevices = async () => {
|
||||
state.audioDevices = await RecordPlugin.getAvailableAudioDevices();
|
||||
};
|
||||
@@ -171,23 +163,9 @@ const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Mic
|
||||
className={clsx("size-4 text-[#999]", {
|
||||
hidden: modifierKeyPressed,
|
||||
})}
|
||||
onClick={startRecording}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
|
||||
{
|
||||
hidden: !modifierKeyPressed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{voiceInput}
|
||||
</div>
|
||||
<VisibleKey shortcut={voiceInput} onKeyPress={startRecording}>
|
||||
<Mic className="size-4 text-[#999]" onClick={startRecording} />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
82
src/components/ChatMessage/CallTools.tsx
Normal file
82
src/components/ChatMessage/CallTools.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Loader, Hammer, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { IChunkData } from "@/components/Assistant/types";
|
||||
import Markdown from "./Markdown";
|
||||
|
||||
interface CallToolsProps {
|
||||
Detail?: any;
|
||||
ChunkData?: IChunkData;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const CallTools = ({ Detail, ChunkData, loading }: CallToolsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isThinkingExpanded, setIsThinkingExpanded] = useState(false);
|
||||
|
||||
const [Data, setData] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!Detail?.description) return;
|
||||
setData(Detail?.description);
|
||||
}, [Detail?.description]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
setData(ChunkData?.message_chunk);
|
||||
}, [ChunkData?.message_chunk, Data]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-3 w-full">
|
||||
<button
|
||||
onClick={() => setIsThinkingExpanded((prev) => !prev)}
|
||||
className="inline-flex items-center gap-2 px-2 py-1 rounded-xl transition-colors border border-[#E6E6E6] dark:border-[#272626]"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader className="w-4 h-4 animate-spin text-[#1990FF]" />
|
||||
<span className="text-xs text-[#999999] italic">
|
||||
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Hammer className="w-4 h-4 text-[#38C200]" />
|
||||
<span className="text-xs text-[#999999]">
|
||||
{t(`assistant.message.steps.${ChunkData?.chunk_type}`)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isThinkingExpanded ? (
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<Markdown
|
||||
content={Data || ""}
|
||||
loading={loading}
|
||||
onDoubleClickCapture={() => {}}
|
||||
/>
|
||||
{/* {Data?.split("\n").map(
|
||||
(paragraph, idx) =>
|
||||
paragraph.trim() && (
|
||||
<p key={idx} className="text-sm">
|
||||
{paragraph}
|
||||
</p>
|
||||
)
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const DeepRead = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -84,7 +84,7 @@ export const DeepRead = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-3 text-xs">
|
||||
{Data?.map((item) => (
|
||||
|
||||
@@ -34,8 +34,13 @@ interface ISourceData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
export const FetchSource = ({
|
||||
Detail,
|
||||
ChunkData,
|
||||
loading,
|
||||
}: FetchSourceProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isSourceExpanded, setIsSourceExpanded] = useState(false);
|
||||
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -51,30 +56,34 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
|
||||
try {
|
||||
const match = ChunkData.message_chunk.match(
|
||||
/\u003cPayload total=(\d+)\u003e/
|
||||
);
|
||||
if (match) {
|
||||
setTotal(Number(match[1]));
|
||||
}
|
||||
if (!loading) {
|
||||
try {
|
||||
const match = ChunkData.message_chunk.match(
|
||||
// /\u003cPayload total=(\d+)\u003e/
|
||||
/<Payload total=(\d+)>/
|
||||
);
|
||||
if (match) {
|
||||
setTotal(Number(match[1]));
|
||||
}
|
||||
|
||||
const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
||||
if (jsonMatch) {
|
||||
const jsonData = JSON.parse(jsonMatch[0]);
|
||||
setData(jsonData);
|
||||
// const jsonMatch = ChunkData.message_chunk.match(/\[(.*)\]/s);
|
||||
const jsonMatch = ChunkData.message_chunk.match(/\[([\s\S]*)\]/);
|
||||
if (jsonMatch) {
|
||||
const jsonData = JSON.parse(jsonMatch[0]);
|
||||
setData(jsonData);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse fetch source data:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to parse fetch source data:", e);
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
}, [ChunkData?.message_chunk, loading]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mt-2 mb-2 w-[610px] ${
|
||||
className={`mt-2 mb-2 max-w-full w-full md:w-[610px] ${
|
||||
isSourceExpanded
|
||||
? "rounded-lg overflow-hidden border border-[#E6E6E6] dark:border-[#272626]"
|
||||
: ""
|
||||
@@ -120,13 +129,15 @@ export const FetchSource = ({ Detail, ChunkData }: FetchSourceProps) => {
|
||||
className="group flex items-center p-2 hover:bg-[#F7F7F7] dark:hover:bg-[#2C2C2C] border-b border-[#E6E6E6] dark:border-[#272626] last:border-b-0 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<div className="w-[75%] flex items-center gap-1">
|
||||
<div className="w-full md:w-[75%] flex items-center gap-1">
|
||||
<Globe className="w-3 h-3 flex-shrink-0" />
|
||||
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
|
||||
{item.title || item.category}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[25%] flex items-center justify-end gap-2">
|
||||
<div
|
||||
className={`flex mobile:hidden w-[25%] items-center justify-end gap-2`}
|
||||
>
|
||||
<span className="text-xs text-[#999999] dark:text-[#999999] truncate">
|
||||
{item.source?.name}
|
||||
</span>
|
||||
|
||||
@@ -9,11 +9,12 @@ import RehypeHighlight from "rehype-highlight";
|
||||
import mermaid from "mermaid";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { copyToClipboard,
|
||||
// useWindowSize
|
||||
import {
|
||||
copyToClipboard,
|
||||
// useWindowSize
|
||||
} from "@/utils";
|
||||
|
||||
import "./markdown.css";
|
||||
import "./markdown.scss";
|
||||
import "./highlight.css";
|
||||
|
||||
// 8
|
||||
@@ -296,18 +297,20 @@ export default function Markdown(
|
||||
const mdRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
fontFamily: props.fontFamily || "inherit",
|
||||
}}
|
||||
ref={mdRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||
dir="auto"
|
||||
>
|
||||
<MarkdownContent content={props.content} />
|
||||
<div className="coco-chat">
|
||||
<div
|
||||
className="markdown-body"
|
||||
style={{
|
||||
fontSize: `${props.fontSize ?? 14}px`,
|
||||
fontFamily: props.fontFamily || "inherit",
|
||||
}}
|
||||
ref={mdRef}
|
||||
onContextMenu={props.onContextMenu}
|
||||
onDoubleClickCapture={props.onDoubleClickCapture}
|
||||
dir="auto"
|
||||
>
|
||||
<MarkdownContent content={props.content} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PickSource = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk, loading]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -103,7 +103,7 @@ export const PickSource = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-3 text-xs">
|
||||
{Data?.map((item) => (
|
||||
|
||||
45
src/components/ChatMessage/PrevSuggestion.tsx
Normal file
45
src/components/ChatMessage/PrevSuggestion.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MoveRight } from "lucide-react";
|
||||
import { FC, useEffect, useState } from "react";
|
||||
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface PrevSuggestionProps {
|
||||
sendMessage: (message: string) => void;
|
||||
}
|
||||
|
||||
const PrevSuggestion: FC<PrevSuggestionProps> = (props) => {
|
||||
const { sendMessage } = props;
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
const [list, setList] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const suggested = currentAssistant?._source?.chat_settings?.suggested || {};
|
||||
if (suggested.enabled) {
|
||||
setList(suggested.questions || []);
|
||||
} else {
|
||||
setList([]);
|
||||
}
|
||||
}, [JSON.stringify(currentAssistant)]);
|
||||
|
||||
return (
|
||||
<ul className="absolute left-2 bottom-2 flex flex-col gap-2">
|
||||
{list.map((item) => {
|
||||
return (
|
||||
<li
|
||||
key={item}
|
||||
className="flex items-center self-start gap-2 px-3 py-2 leading-4 text-sm text-[#333] dark:text-[#d8d8d8] rounded-xl border border-black/15 dark:border-white/15 hover:!border-[#0072ff] hover:!text-[#0072ff] transition cursor-pointer"
|
||||
onClick={() => sendMessage(item)}
|
||||
>
|
||||
{item}
|
||||
|
||||
<MoveRight className="size-4" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrevSuggestion;
|
||||
@@ -66,7 +66,7 @@ export const QueryIntent = ({
|
||||
}
|
||||
}, [ChunkData?.message_chunk]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -97,7 +97,7 @@ export const QueryIntent = ({
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
<div className="mb-4 space-y-2 text-xs">
|
||||
{Data?.keyword ? (
|
||||
|
||||
@@ -26,7 +26,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
||||
setData(ChunkData?.message_chunk);
|
||||
}, [ChunkData?.message_chunk, Data]);
|
||||
|
||||
// Must be after hooks !!!
|
||||
// Must be after hooks !!!
|
||||
if (!ChunkData && !Detail) return null;
|
||||
|
||||
return (
|
||||
@@ -57,7 +57,7 @@ export const Think = ({ Detail, ChunkData, loading }: ThinkProps) => {
|
||||
)}
|
||||
</button>
|
||||
{isThinkingExpanded && (
|
||||
<div className="pl-2 border-l-2 border-[e5e5e5]">
|
||||
<div className="pl-2 border-l-2 border-[#e5e5e5] dark:border-[#4e4e56]">
|
||||
<div className="text-[#8b8b8b] dark:text-[#a6a6a6] space-y-2">
|
||||
{Data?.split("\n").map(
|
||||
(paragraph, idx) =>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import type { Message, IChunkData } from "@/components/Assistant/types";
|
||||
import { QueryIntent } from "./QueryIntent";
|
||||
import { CallTools } from "./CallTools";
|
||||
import { FetchSource } from "./FetchSource";
|
||||
import { PickSource } from "./PickSource";
|
||||
import { DeepRead } from "./DeepRead";
|
||||
@@ -12,11 +14,14 @@ import { MessageActions } from "./MessageActions";
|
||||
import Markdown from "./Markdown";
|
||||
import { SuggestionList } from "./SuggestionList";
|
||||
import { UserMessage } from "./UserMessage";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import FontIcon from "@/components/Common/Icons/FontIcon";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isTyping?: boolean;
|
||||
query_intent?: IChunkData;
|
||||
tools?: IChunkData;
|
||||
fetch_source?: IChunkData;
|
||||
pick_source?: IChunkData;
|
||||
deep_read?: IChunkData;
|
||||
@@ -30,6 +35,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
message,
|
||||
isTyping,
|
||||
query_intent,
|
||||
tools,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
@@ -40,7 +46,24 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}: ChatMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
const assistantList = useConnectStore((state) => state.assistantList);
|
||||
const [assistant, setAssistant] = useState<any>({});
|
||||
|
||||
const isAssistant = message?._source?.type === "assistant";
|
||||
const assistant_id = message?._source?.assistant_id;
|
||||
|
||||
useEffect(() => {
|
||||
let target = currentAssistant;
|
||||
if (isAssistant && assistant_id && Array.isArray(assistantList)) {
|
||||
const found = assistantList.find((item) => item._id === assistant_id);
|
||||
if (found) {
|
||||
target = found;
|
||||
}
|
||||
}
|
||||
setAssistant(target);
|
||||
}, [isAssistant, assistant_id, assistantList, currentAssistant]);
|
||||
|
||||
const messageContent = message?._source?.message || "";
|
||||
const details = message?._source?.details || [];
|
||||
const question = message?._source?.question || "";
|
||||
@@ -49,6 +72,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
isTyping === false && (messageContent || response?.message_chunk);
|
||||
|
||||
const [suggestion, setSuggestion] = useState<string[]>([]);
|
||||
const visibleStartPage = useConnectStore((state) => state.visibleStartPage);
|
||||
|
||||
const getSuggestion = (suggestion: string[]) => {
|
||||
setSuggestion(suggestion);
|
||||
@@ -67,6 +91,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
getSuggestion={getSuggestion}
|
||||
loading={loadingStep?.query_intent}
|
||||
/>
|
||||
|
||||
<CallTools
|
||||
Detail={details.find((item) => item.type === "tools")}
|
||||
ChunkData={tools}
|
||||
loading={loadingStep?.tools}
|
||||
/>
|
||||
|
||||
<FetchSource
|
||||
Detail={details.find((item) => item.type === "fetch_source")}
|
||||
ChunkData={fetch_source}
|
||||
@@ -117,7 +148,13 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`py-8 flex ${isAssistant ? "justify-start" : "justify-end"}`}
|
||||
className={clsx(
|
||||
"py-8 flex",
|
||||
[isAssistant ? "justify-start" : "justify-end"],
|
||||
{
|
||||
hidden: visibleStartPage,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`px-4 flex gap-4 ${
|
||||
@@ -125,20 +162,28 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`space-y-2 ${isAssistant ? "text-left" : "text-right"}`}
|
||||
className={`w-full space-y-2 ${
|
||||
isAssistant ? "text-left" : "text-right"
|
||||
}`}
|
||||
>
|
||||
<p className="flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
<div className="w-full flex items-center gap-1 font-semibold text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
{isAssistant ? (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-6 h-6"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
<div className="w-6 h-6 flex justify-center items-center rounded-full bg-white border border-[#E6E6E6]">
|
||||
{assistant?._source?.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={assistant._source.icon} className="w-4 h-4" />
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4"
|
||||
alt={t("assistant.message.logo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{isAssistant ? t("assistant.message.aiName") : ""}
|
||||
</p>
|
||||
<div className="prose dark:prose-invert prose-sm max-w-none">
|
||||
<div className="pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||
{isAssistant ? assistant?._source?.name || "Coco AI" : ""}
|
||||
</div>
|
||||
<div className="w-full prose dark:prose-invert prose-sm max-w-none">
|
||||
<div className="w-full pl-7 text-[#333] dark:text-[#d8d8d8] leading-relaxed">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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
@@ -44,8 +44,8 @@ export default function Cloud() {
|
||||
|
||||
const SidebarRef = useRef<{ refreshData: () => void }>(null);
|
||||
|
||||
const error = useAppStore((state) => state.error);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const errors = useAppStore((state) => state.errors);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const [isConnect, setIsConnect] = useState(true);
|
||||
|
||||
@@ -70,14 +70,13 @@ export default function Cloud() {
|
||||
console.log("currentService", currentService);
|
||||
setLoading(false);
|
||||
setRefreshLoading(false);
|
||||
setError("");
|
||||
setIsConnect(true);
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
list_coco_servers()
|
||||
.then((res: any) => {
|
||||
if (error) {
|
||||
if (errors.length > 0) {
|
||||
res = (res || []).map((item: any) => {
|
||||
if (item.id === currentService?.id) {
|
||||
item.health = {
|
||||
@@ -102,7 +101,6 @@ export default function Cloud() {
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
@@ -123,21 +121,10 @@ export default function Cloud() {
|
||||
return add_coco_server(endpointLink)
|
||||
.then((res: any) => {
|
||||
// console.log("add_coco_server", res);
|
||||
fetchServers(false)
|
||||
.then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("fetchServers failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up to outer promise chain
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error("add coco server failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
fetchServers(false).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
setCurrentService(res);
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
@@ -147,7 +134,7 @@ export default function Cloud() {
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code || !serverId) {
|
||||
setError("No authorization code received");
|
||||
addError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,15 +150,9 @@ export default function Cloud() {
|
||||
refreshClick(serverId);
|
||||
}
|
||||
|
||||
getCurrentWindow()
|
||||
.setFocus()
|
||||
.catch((err) => {
|
||||
setError(err);
|
||||
});
|
||||
getCurrentWindow().setFocus();
|
||||
} catch (e) {
|
||||
console.error("Sign in failed:", e);
|
||||
setError("SSO login failed: " + e);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -190,7 +171,7 @@ export default function Cloud() {
|
||||
|
||||
if (reqId != ssoRequestID) {
|
||||
console.log("Request ID not matched, skip");
|
||||
setError("Request ID not matched, skip");
|
||||
addError("Request ID not matched, skip");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,7 +179,7 @@ export default function Cloud() {
|
||||
handleOAuthCallback(code, serverId);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse URL:", err);
|
||||
setError("Invalid URL format: " + err);
|
||||
addError("Invalid URL format: " + err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -234,7 +215,7 @@ export default function Cloud() {
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to get initial URLs:", err);
|
||||
setError("Failed to get initial URLs: " + err);
|
||||
addError("Failed to get initial URLs: " + err);
|
||||
});
|
||||
|
||||
const unlisten = onOpenUrl((urls) => handleUrl(urls[0]));
|
||||
@@ -275,10 +256,6 @@ export default function Cloud() {
|
||||
setCurrentService(res);
|
||||
emit("login_or_logout", true);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
@@ -297,45 +274,31 @@ export default function Cloud() {
|
||||
refreshClick(id);
|
||||
emit("login_or_logout", false);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
const removeServer = (id: string) => {
|
||||
remove_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// TODO display the error message
|
||||
setError(err);
|
||||
console.error(err);
|
||||
remove_coco_server(id).then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then((r) => {
|
||||
console.log("fetchServers", r);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const enable_coco_server = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
try {
|
||||
if (enabled) {
|
||||
await enable_server(currentService?.id);
|
||||
} else {
|
||||
await disable_server(currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
await fetchServers(false);
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
if (enabled) {
|
||||
await enable_server(currentService?.id);
|
||||
} else {
|
||||
await disable_server(currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
await fetchServers(false);
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
@@ -473,8 +436,7 @@ export default function Cloud() {
|
||||
<Copy className="inline mr-2" />{" "}
|
||||
</button>
|
||||
<div className="text-justify italic text-xs">
|
||||
If the link did not open automatically, please copy
|
||||
and paste it into your browser manually.
|
||||
{t("cloud.manualCopyLink")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,9 +13,8 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
const { t } = useTranslation();
|
||||
const [endpointLink, setEndpointLink] = useState("");
|
||||
const [refreshLoading] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState(""); // State to store the error message
|
||||
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
const addError = useAppStore((state) => state.addError);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -36,17 +35,10 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err?.message || "An unknown error occurred.";
|
||||
setErrorMessage("ERR:" + errorMessage);
|
||||
setError(errorMessage);
|
||||
console.error("Error:", errorMessage);
|
||||
addError(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to close the error message
|
||||
const closeError = () => {
|
||||
setErrorMessage("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center gap-2 mb-8">
|
||||
@@ -96,31 +88,6 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/*//TODO move to outer container, move error state to global*/}
|
||||
{errorMessage && (
|
||||
<div className="mb-8">
|
||||
<div
|
||||
style={{
|
||||
color: "red",
|
||||
marginTop: "10px",
|
||||
display: "block", // Makes sure the error message starts on a new line
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<span>{errorMessage}</span>
|
||||
<button
|
||||
onClick={closeError}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "red",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import source_default_img from "@/assets/images/source_default.png";
|
||||
import source_default_dark_img from "@/assets/images/source_default_dark.png";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
|
||||
interface Account {
|
||||
email: string;
|
||||
@@ -13,11 +14,12 @@ interface Account {
|
||||
|
||||
interface DataSourceItemProps {
|
||||
name: string;
|
||||
icon?: string;
|
||||
connector: any;
|
||||
accounts?: Account[];
|
||||
}
|
||||
|
||||
export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
||||
export function DataSourceItem({ name, icon, connector }: DataSourceItemProps) {
|
||||
// const isConnected = true;
|
||||
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
@@ -56,7 +58,12 @@ export function DataSourceItem({ name, connector }: DataSourceItemProps) {
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<img src={getTypeIcon()} alt={name} className="w-6 h-6" />
|
||||
{icon?.startsWith("font_") ? (
|
||||
<FontIcon name={icon} className="size-6" />
|
||||
) : (
|
||||
<img src={getTypeIcon()} alt={name} className="size-6" />
|
||||
)}
|
||||
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
@@ -4,16 +4,15 @@ import { RefreshCcw } from "lucide-react";
|
||||
|
||||
import { DataSourceItem } from "./DataSourceItem";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import {
|
||||
get_connectors_by_server,
|
||||
get_datasources_by_server,
|
||||
datasource_search,
|
||||
} from "@/commands";
|
||||
|
||||
export function DataSourcesList({ server }: { server: string }) {
|
||||
const { t } = useTranslation();
|
||||
const datasourceData = useConnectStore((state) => state.datasourceData);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false);
|
||||
const setDatasourceData = useConnectStore((state) => state.setDatasourceData);
|
||||
const setConnectorData = useConnectStore((state) => state.setConnectorData);
|
||||
@@ -25,22 +24,14 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {});
|
||||
|
||||
//fetch datasource data
|
||||
get_datasources_by_server(server)
|
||||
datasource_search(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_datasources_by_server", res);
|
||||
// console.log("datasource_search", res);
|
||||
setDatasourceData(res, server);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
})
|
||||
.finally(() => {});
|
||||
}
|
||||
|
||||
@@ -48,8 +39,6 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
setRefreshLoading(true);
|
||||
try {
|
||||
initServerAppData({ server });
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setRefreshLoading(false);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
return (
|
||||
<div
|
||||
key={item?.id}
|
||||
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 ${
|
||||
className={`flex cursor-pointer items-center space-x-2 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded-lg mb-2 whitespace-nowrap ${
|
||||
currentService?.id === item?.id
|
||||
? "dark:bg-blue-900/20 dark:bg-blue-900 border border-[#0087ff]"
|
||||
: "bg-gray-50 dark:bg-gray-900 border border-[#e6e6e6] dark:border-gray-700"
|
||||
@@ -37,9 +37,9 @@ export const Sidebar = forwardRef<{ refreshData: () => void }, SidebarProps>(
|
||||
<img
|
||||
src={item?.provider?.icon || cocoLogoImg}
|
||||
alt="LogoImg"
|
||||
className="w-5 h-5"
|
||||
className="w-5 h-5 flex-shrink-0"
|
||||
/>
|
||||
<span className="font-medium">{item?.name}</span>
|
||||
<span className="font-medium truncate max-w-[140px]">{item?.name}</span>
|
||||
<div className="flex-1" />
|
||||
<button className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300">
|
||||
{item.health?.status ? (
|
||||
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
27
src/components/Common/Icons/FontIcon.tsx
Normal file
27
src/components/Common/Icons/FontIcon.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useIconfontScript } from "@/hooks/useScript";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
|
||||
interface FontIconProps {
|
||||
name: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const FontIcon = ({ name, className, style, ...rest }: FontIconProps) => {
|
||||
useIconfontScript();
|
||||
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
|
||||
if (isTauri) {
|
||||
return (
|
||||
<svg className={`icon ${className || ""}`} style={style} {...rest}>
|
||||
<use xlinkHref={`#${name}`} />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return <img src={logoImg} className={className} alt={"coco"} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default FontIcon;
|
||||
@@ -1,9 +1,14 @@
|
||||
import { File } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { isEmpty, isNil } from "lodash-es";
|
||||
|
||||
import IconWrapper from "./IconWrapper";
|
||||
import ThemedIcon from "./ThemedIcon";
|
||||
import { useFindConnectorIcon } from "@/hooks/useFindConnectorIcon";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import FontIcon from "./FontIcon";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface ItemIconProps {
|
||||
item: any;
|
||||
@@ -11,60 +16,94 @@ interface ItemIconProps {
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ItemIcon({
|
||||
const ItemIcon = React.memo(function ItemIcon({
|
||||
item,
|
||||
className = "w-5 h-5 flex-shrink-0",
|
||||
onClick = () => {},
|
||||
}: ItemIconProps) {
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
|
||||
const connectorSource = useFindConnectorIcon(item);
|
||||
// console.log("connectorSource", connectorSource);
|
||||
const icons = connectorSource?.assets?.icons || {};
|
||||
const [isAbsolute, setIsAbsolute] = useState<boolean>();
|
||||
|
||||
// If the icon is a valid base64-encoded image
|
||||
const isBase64 = item?.icon?.startsWith("data:image/");
|
||||
if (isBase64) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={item?.icon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
useAsyncEffect(async () => {
|
||||
if (isEmpty(item)) return;
|
||||
|
||||
let selectedIcon = icons[item?.icon];
|
||||
if (!selectedIcon) {
|
||||
selectedIcon=item?.icon
|
||||
}
|
||||
try {
|
||||
const { isAbsolute } = await platformAdapter.metadata(item.icon, {
|
||||
omitSize: true,
|
||||
});
|
||||
setIsAbsolute(Boolean(isAbsolute));
|
||||
} catch (error) {
|
||||
setIsAbsolute(false);
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
if (!selectedIcon) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<ThemedIcon component={File} className={className} />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
const renderIcon = () => {
|
||||
if (isNil(isAbsolute)) return;
|
||||
|
||||
if (
|
||||
selectedIcon.startsWith("http://") ||
|
||||
selectedIcon.startsWith("https://")
|
||||
) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={selectedIcon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img
|
||||
className={className}
|
||||
src={`${endpoint_http}${selectedIcon}`}
|
||||
alt="icon"
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isAbsolute) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img
|
||||
className={className}
|
||||
src={platformAdapter.convertFileSrc(item?.icon)}
|
||||
alt="icon"
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
} else {
|
||||
if (item?.icon?.startsWith("font_")) {
|
||||
return <FontIcon name={item?.icon} className={className} />;
|
||||
}
|
||||
|
||||
// If the icon is a valid base64-encoded image
|
||||
const isBase64 = item?.icon?.startsWith("data:image/");
|
||||
if (isBase64) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={item?.icon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
let selectedIcon = icons[item?.icon];
|
||||
if (!selectedIcon) {
|
||||
selectedIcon = item?.icon;
|
||||
}
|
||||
|
||||
if (!selectedIcon) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<ThemedIcon component={File} className={className} />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
selectedIcon.startsWith("http://") ||
|
||||
selectedIcon.startsWith("https://")
|
||||
) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={selectedIcon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img
|
||||
className={className}
|
||||
src={`${endpoint_http}${selectedIcon}`}
|
||||
alt="icon"
|
||||
/>
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return renderIcon();
|
||||
});
|
||||
|
||||
export default ItemIcon;
|
||||
|
||||
@@ -20,6 +20,29 @@ function TypeIcon({
|
||||
const endpoint_http = useAppStore((state) => state.endpoint_http);
|
||||
const connectorSource = useFindConnectorIcon(item);
|
||||
|
||||
const isCalculator = item.id === "Calculator";
|
||||
|
||||
if (isCalculator) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src="/assets/calculator.png" alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (item?.source?.icon) {
|
||||
if (
|
||||
item?.source?.icon.startsWith("http://") ||
|
||||
item?.source?.icon.startsWith("https://")
|
||||
) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
<img className={className} src={item?.source?.icon} alt="icon" />
|
||||
</IconWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If the icon is a valid base64-encoded image
|
||||
const isBase64 = connectorSource?.icon?.startsWith("data:image/");
|
||||
if (isBase64) {
|
||||
|
||||
132
src/components/Common/NoDataImage.tsx
Normal file
132
src/components/Common/NoDataImage.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { FC, useMemo } from "react";
|
||||
|
||||
interface NoDataImageProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
const NoDataImage: FC<NoDataImageProps> = (props) => {
|
||||
const { size = 64 } = props;
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
|
||||
const color = useMemo(() => {
|
||||
return isDark ? "#666" : "#bbb";
|
||||
}, [isDark]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 64 64"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
>
|
||||
<title>编组</title>
|
||||
<defs>
|
||||
<rect id="path-1" x="0" y="0" width="64" height="64"></rect>
|
||||
</defs>
|
||||
<g
|
||||
id="聊天记录"
|
||||
stroke="none"
|
||||
strokeWidth="1"
|
||||
fill="none"
|
||||
fillRule="evenodd"
|
||||
>
|
||||
<g id="画板" transform="translate(-1164, -252)">
|
||||
<g id="编组" transform="translate(1164, 252)">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlinkHref="#path-1"></use>
|
||||
</mask>
|
||||
<g id="矩形"></g>
|
||||
<path
|
||||
d="M42.6791711,10.4781665 C43.2314559,10.4781665 43.6791711,10.9258817 43.6791711,11.4781665 C43.6791711,12.0304512 43.2314559,12.4781665 42.6791711,12.4781665 L22.9821691,12.4781665 C20.8942749,12.4781665 19.1821691,14.272673 19.1821691,16.5091825 L19.1821691,52.5647975 C19.1821691,54.801307 20.8942749,56.5958135 22.9821691,56.5958135 L52.5821691,56.5958135 C54.6700634,56.5958135 56.3821691,54.801307 56.3821691,52.5647975 L56.3821691,35.1361358 C56.3821691,34.5838511 56.8298844,34.1361358 57.3821691,34.1361358 C57.9344539,34.1361358 58.3821691,34.5838511 58.3821691,35.1361358 L58.3821691,52.5647975 C58.3821691,55.8853949 55.7962085,58.5958135 52.5821691,58.5958135 L22.9821691,58.5958135 C19.7681298,58.5958135 17.1821691,55.8853949 17.1821691,52.5647975 L17.1821691,16.5091825 C17.1821691,13.1885852 19.7681298,10.4781665 22.9821691,10.4781665 L42.6791711,10.4781665 Z"
|
||||
id="路径"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
transform="translate(37.7822, 34.537) scale(-1, -1) translate(-37.7822, -34.537)"
|
||||
></path>
|
||||
<path
|
||||
d="M13.0320895,0.82004591 C13.5510674,0.631153401 14.1249097,0.898740484 14.3138023,1.41771839 L15.8235355,5.56567639 C16.012428,6.0846543 15.7448409,6.65849665 15.225863,6.84738916 C14.7068851,7.03628167 14.1330428,6.76869458 13.9441503,6.24971668 L12.434417,2.10175867 C12.2455245,1.58278077 12.5131116,1.00893842 13.0320895,0.82004591 Z"
|
||||
id="路径-4"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M21.302281,2.6499257 C21.6572828,2.22685104 22.2880384,2.17166708 22.7111131,2.52666887 C23.1341877,2.88167066 23.1893717,3.51242626 22.8343699,3.93550092 L19.9969995,7.31694727 C19.6419977,7.74002193 19.0112421,7.7952059 18.5881675,7.4402041 C18.1650928,7.08520231 18.1099088,6.45444672 18.4649106,6.03137205 L21.302281,2.6499257 Z"
|
||||
id="路径-4"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M12.7902454,12.7943281 C13.1452472,12.3712534 13.7760028,12.3160695 14.1990774,12.6710713 C14.6221521,13.0260731 14.677336,13.6568287 14.3223342,14.0799033 L11.4849639,17.4613497 C11.1299621,17.8844243 10.4992065,17.9396083 10.0761318,17.5846065 C9.65305715,17.2296047 9.59787319,16.5988491 9.95287498,16.1757745 L12.7902454,12.7943281 Z"
|
||||
id="路径-4"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M7.75703715,7.52422963 L7.8730548,7.53785515 L12.2201584,8.30436681 C12.7640527,8.40027005 13.1272212,8.91892844 13.031318,9.46282274 C12.9354148,10.006717 12.4167564,10.3698856 11.8728621,10.2739823 L7.52575844,9.50747066 C6.98186414,9.41156742 6.61869562,8.89290903 6.71459887,8.34901473 C6.81050211,7.80512042 7.32916049,7.44195191 7.8730548,7.53785515 L7.75703715,7.52422963 Z"
|
||||
id="路径-4"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<g id="编组-7" mask="url(#mask-2)" fill={color} fillRule="nonzero">
|
||||
<g transform="translate(21.7188, 46.9969) rotate(18) translate(-21.7188, -46.9969)translate(1.0617, 26.0967)">
|
||||
<path
|
||||
d="M29.4293481,8.47638559 C29.6966411,8.47287826 29.9542113,8.57653439 30.1445628,8.76421534 L34.821948,13.37598 C35.2152213,13.7637359 35.219694,14.3968851 34.8319381,14.7901583 C34.4441822,15.1834316 33.811033,15.1879043 33.4177598,14.8001484 L29.0372657,10.4802995 L10.8832657,10.7192995 L7.15822377,14.4638747 C6.79868609,14.8253024 6.23152968,14.8545189 5.83844017,14.5505235 L5.74401507,14.4675822 C5.35246833,14.078083 5.35080843,13.4449202 5.74030758,13.0533735 L9.75247731,9.02011185 C9.93695686,8.8346625 10.1867572,8.72888061 10.4483149,8.72544853 L29.4293481,8.47638559 Z"
|
||||
id="路径"
|
||||
></path>
|
||||
<path
|
||||
d="M21.9935139,5.99478408 C22.1098028,5.454881 22.6417515,5.11147388 23.1816546,5.22776279 C30.6189355,6.82966762 36.013841,13.4326353 36.013841,21.1370431 C36.013841,30.1250238 28.7276429,37.4112219 19.7396622,37.4112219 C13.7007194,37.4112219 8.24610098,34.0926516 5.42139758,28.8785984 C5.15832308,28.3929954 5.33871807,27.7860724 5.82432105,27.5229979 C6.30992403,27.2599235 6.91684704,27.4403184 7.17992153,27.9259214 C9.65840757,32.5009002 14.442002,35.4112219 19.7396622,35.4112219 C27.6230734,35.4112219 34.013841,29.0204543 34.013841,21.1370431 C34.013841,14.3797233 29.2812816,8.58741864 22.7605351,7.18292479 C22.2206321,7.06663587 21.8772249,6.53468716 21.9935139,5.99478408 Z"
|
||||
id="路径"
|
||||
transform="translate(20.6572, 21.3082) rotate(54) translate(-20.6572, -21.3082)"
|
||||
></path>
|
||||
<path
|
||||
d="M24.8408727,10.2022416 C25.1806942,9.48260473 26.1841494,9.42898516 26.5987243,10.1083109 C28.1725233,12.6871503 30.5237061,13.5670326 33.8949611,12.7889293 L34.3447467,14.7376963 C30.8069102,15.5542475 27.9486137,14.8138981 25.9168841,12.5414695 L25.7964185,12.4022407 L25.7898204,12.4127995 C25.6463328,12.5945684 25.4927076,12.7728669 25.3285985,12.9461789 L25.0745286,13.2022175 C23.8489955,14.3805113 22.3059749,15.0880642 20.4542955,15.0880642 C18.3100422,15.0880642 16.4792823,14.2999241 15.006077,12.7631502 L14.8394185,12.5842407 L14.8279264,12.601442 C13.2192845,14.7385979 10.485016,15.3662742 6.82944359,14.516796 L6.52268429,14.4425875 L7.01015268,12.5029033 C10.675886,13.4241508 12.8140347,12.7029257 13.7976491,10.3982465 C14.1109032,9.66426959 15.1154408,9.57488536 15.5533666,10.2420219 C16.8166654,12.1665323 18.4200295,13.0880642 20.4542955,13.0880642 C21.7518359,13.0880642 22.8143045,12.6008686 23.6883743,11.7604906 C24.2529251,11.217701 24.6339476,10.6404451 24.8408727,10.2022416 Z"
|
||||
id="路径-16"
|
||||
></path>
|
||||
<path
|
||||
d="M12.8244173,23.0152776 C13.1494608,21.9675928 14.6803967,22.1318925 14.7757241,23.2246913 C15.2805735,29.0121052 16.9225833,32.5211991 19.5882786,33.8687679 C22.99019,35.5885105 25.2983354,32.9466769 25.9867311,26.7356659 C26.1186972,25.5450097 27.8517173,25.5511021 27.9753088,26.7426567 C28.3760471,30.6062039 28.258208,33.1868933 27.5394161,34.6115345 C27.2906367,35.1046138 26.6892413,35.302658 26.196162,35.0538786 C25.8791824,34.8939489 25.6841306,34.5882954 25.6513323,34.2593739 L25.6474421,34.1795936 L25.5912925,34.2588436 C23.9966984,36.4166078 21.7167087,37.0904273 18.9087496,35.7626542 L18.6859746,35.6536621 C16.4357028,34.5160993 14.8302856,32.3368304 13.837234,29.149295 L13.7894421,28.9905936 L13.7871985,29.0725484 C13.7433455,31.0241936 14.0745615,32.6398281 14.6834347,33.7216018 L14.7874678,33.8969203 C15.0804319,34.3650982 14.938393,34.9821256 14.4702152,35.2750897 C14.0020373,35.5680537 13.3850099,35.4260149 13.0920458,34.957837 C11.5454425,32.4862525 11.2726605,28.0169212 12.8244173,23.0152776 Z"
|
||||
id="路径-17"
|
||||
></path>
|
||||
<path
|
||||
d="M34.9080784,-0.312138826 C35.4592592,-0.347040678 35.9343731,0.0714861262 35.9692749,0.622666955 C36.0041768,1.17384778 35.58565,1.64896167 35.0344692,1.68386352 L26.467279,2.22586235 L24.1964917,8.55308868 C24.0232482,9.03577628 23.5171178,9.30318989 23.0295024,9.18955089 L22.9174644,9.1564868 C22.397647,8.96991696 22.1274965,8.39727695 22.3140663,7.87745954 L24.8068618,0.932078859 C24.9416364,0.556572435 25.2867164,0.297104704 25.6848791,0.271892256 L34.9080784,-0.312138826 Z"
|
||||
id="路径-20"
|
||||
transform="translate(29.1132, 4.4507) rotate(-7) translate(-29.1132, -4.4507)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
<path
|
||||
d="M49.865098,19.7058824 C50.4173828,19.7058824 50.865098,20.1535976 50.865098,20.7058824 C50.865098,21.2581671 50.4173828,21.7058824 49.865098,21.7058824 L26.0259167,21.7058824 C25.4736319,21.7058824 25.0259167,21.2581671 25.0259167,20.7058824 C25.0259167,20.1535976 25.4736319,19.7058824 26.0259167,19.7058824 L49.865098,19.7058824 Z"
|
||||
id="路径-7"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M49.865098,39.4705882 C50.4173828,39.4705882 50.865098,39.9183035 50.865098,40.4705882 C50.865098,41.022873 50.4173828,41.4705882 49.865098,41.4705882 L43.2941176,41.4705882 C42.7418329,41.4705882 42.2941176,41.022873 42.2941176,40.4705882 C42.2941176,39.9183035 42.7418329,39.4705882 43.2941176,39.4705882 L49.865098,39.4705882 Z"
|
||||
id="路径-7备份"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
<path
|
||||
d="M49.865098,47 C50.4173828,47 50.865098,47.4477153 50.865098,48 C50.865098,48.5522847 50.4173828,49 49.865098,49 L43.2941176,49 C42.7418329,49 42.2941176,48.5522847 42.2941176,48 C42.2941176,47.4477153 42.7418329,47 43.2941176,47 L49.865098,47 Z"
|
||||
id="路径-7备份-2"
|
||||
fill={color}
|
||||
fillRule="nonzero"
|
||||
mask="url(#mask-2)"
|
||||
></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoDataImage;
|
||||
34
src/components/Common/PopoverInput.tsx
Normal file
34
src/components/Common/PopoverInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||
import { Input, InputProps } from "@headlessui/react";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||
|
||||
const PopoverInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => inputRef.current!);
|
||||
|
||||
useKeyPress(
|
||||
"esc",
|
||||
(event) => {
|
||||
if (inputRef.current === document.activeElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
inputRef.current?.blur();
|
||||
|
||||
const parentPanel = inputRef.current?.closest(POPOVER_PANEL_SELECTOR);
|
||||
if (parentPanel instanceof HTMLElement) {
|
||||
parentPanel.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
target: inputRef,
|
||||
}
|
||||
);
|
||||
|
||||
return <Input ref={inputRef} {...props} />;
|
||||
});
|
||||
|
||||
export default PopoverInput;
|
||||
125
src/components/Common/UI/Footer.tsx
Normal file
125
src/components/Common/UI/Footer.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { ArrowDown01, CornerDownLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import Copyright from "@/components/Common/Copyright";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import VisibleKey from "../VisibleKey";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
|
||||
interface FooterProps {
|
||||
isTauri: boolean;
|
||||
openSetting: () => void;
|
||||
setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
isTauri,
|
||||
openSetting,
|
||||
setWindowAlwaysOnTop,
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
const setVisible = useUpdateStore((state) => state.setVisible);
|
||||
const updateInfo = useUpdateStore((state) => state.updateInfo);
|
||||
const fixedWindow = useShortcutsStore((state) => {
|
||||
return state.fixedWindow;
|
||||
});
|
||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await setWindowAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region={isTauri}
|
||||
className="px-4 z-999 mx-[1px] h-8 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-md rounded-t-none overflow-hidden"
|
||||
>
|
||||
{isTauri ? (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
{sourceData?.source?.name ? (
|
||||
<TypeIcon item={sourceData} className="w-4 h-4" />
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={openSetting}
|
||||
alt={t("search.footer.logoAlt")}
|
||||
/>
|
||||
)}
|
||||
<div className="relative text-xs text-gray-500 dark:text-gray-400">
|
||||
{updateInfo?.available ? (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => setVisible(true)}
|
||||
>
|
||||
<span>{t("search.footer.updateAvailable")}</span>
|
||||
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
|
||||
</div>
|
||||
) : (
|
||||
sourceData?.source?.name ||
|
||||
t("search.footer.version", {
|
||||
version: process.env.VERSION || "v1.0.0",
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": updateInfo?.available,
|
||||
})}
|
||||
>
|
||||
<VisibleKey shortcut={fixedWindow} onKeyPress={togglePin}>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</VisibleKey>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Copyright />
|
||||
)}
|
||||
|
||||
<div className={`flex mobile:hidden items-center gap-3`}>
|
||||
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-xs">
|
||||
<span className="mr-1.5">{t("search.footer.select")}:</span>
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
<div className="flex items-center justify-center min-w-3 h-3">
|
||||
{formatKey(modifierKey)}
|
||||
</div>
|
||||
</kbd>
|
||||
+
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
<ArrowDown01 className="w-3 h-3" />
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center text-[#666] dark:text-[#666] text-xs">
|
||||
<span className="mr-1.5">{t("search.footer.open")}: </span>
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
<CornerDownLeft className="w-3 h-3" />
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/Common/UI/NoResults.tsx
Normal file
46
src/components/Common/UI/NoResults.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import clsx from "clsx";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
|
||||
export const NoResults = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col justify-center items-center"
|
||||
>
|
||||
<img src={noDataImg} alt="no-data" className="w-16 h-16" />
|
||||
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
|
||||
{t("search.main.noResults")}
|
||||
</div>
|
||||
<div
|
||||
className={`flex mobile:hidden mt-10 text-sm text-[#333] dark:text-[#D8D8D8]`}
|
||||
>
|
||||
{t("search.main.askCoco")}
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-3 h-5 min-w-5 rounded-md border border-[#D8D8D8] flex justify-center items-center",
|
||||
{
|
||||
"px-1": !isMac,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{formatKey(modifierKey)}
|
||||
</span>
|
||||
|
||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
{modeSwitch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,49 +1,24 @@
|
||||
import {Menu, MenuButton,} from "@headlessui/react";
|
||||
// import { Settings, LogOut, User, ChevronUp, Home } from "lucide-react";
|
||||
// import { Link } from "react-router-dom";
|
||||
import logoImg from "../assets/icon.svg";
|
||||
import {useAppStore} from "@/stores/appStore";
|
||||
import {OctagonAlert, X} from 'lucide-react';
|
||||
import { Menu, MenuButton } from "@headlessui/react";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
|
||||
const Footer = () => {
|
||||
|
||||
const error = useAppStore((state) => state.error);
|
||||
const setError = useAppStore((state) => state.setError);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
|
||||
{/* Move the warning message outside the border */}
|
||||
{error && (
|
||||
<div
|
||||
className="fixed bottom-6 left-0 right-0 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 rounded-lg shadow-lg p-4 m-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<OctagonAlert size={32} color="red" className="mr-2"/>
|
||||
<span className="text-xs text-red-500 dark:text-red-400 flex-1">{error}</span>
|
||||
<X
|
||||
className="cursor-pointer ml-2"
|
||||
onClick={() => setError("")}
|
||||
size={32}
|
||||
color="gray"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton
|
||||
className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<img
|
||||
src={logoImg}
|
||||
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">
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 h-8 flex items-center justify-between">
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="h-7 flex items-center space-x-2 px-1 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
<img
|
||||
src={logoImg}
|
||||
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>
|
||||
{/* <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-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{/* <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="p-1">
|
||||
<MenuItem>
|
||||
{({ active }) => (
|
||||
@@ -104,21 +79,20 @@ const Footer = () => {
|
||||
</MenuItem>
|
||||
</div>
|
||||
</MenuItems> */}
|
||||
</Menu>
|
||||
</Menu>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Version {process.env.VERSION || "v1.0.0"}
|
||||
</span>
|
||||
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
{/* <div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<button className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Check for Updates
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
103
src/components/Common/VisibleKey.tsx
Normal file
103
src/components/Common/VisibleKey.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { FC, HTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { last } from "lodash-es";
|
||||
|
||||
import { POPOVER_PANEL_SELECTOR } from "@/constants";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface VisibleKeyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
shortcut: string;
|
||||
rootClassName?: string;
|
||||
shortcutClassName?: string;
|
||||
onKeyPress?: () => void;
|
||||
}
|
||||
|
||||
const VisibleKey: FC<VisibleKeyProps> = (props) => {
|
||||
const {
|
||||
shortcut,
|
||||
rootClassName,
|
||||
shortcutClassName,
|
||||
children,
|
||||
onKeyPress,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const openPopover = useShortcutsStore((state) => {
|
||||
return state.openPopover;
|
||||
});
|
||||
|
||||
const childrenRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleShortcut, setVisibleShortcut] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
const popoverPanelEls = document.querySelectorAll(POPOVER_PANEL_SELECTOR);
|
||||
|
||||
const popoverPanelEl = last(popoverPanelEls);
|
||||
|
||||
if (!openPopover || !popoverPanelEl) {
|
||||
return setVisibleShortcut(modifierKeyPressed);
|
||||
}
|
||||
|
||||
const popoverButtonEl = document.querySelector(
|
||||
`[aria-controls="${popoverPanelEl.id}"]`
|
||||
);
|
||||
|
||||
const isChildInPanel = popoverPanelEl?.contains(childrenRef.current);
|
||||
const isChildInButton = popoverButtonEl?.contains(childrenRef.current);
|
||||
|
||||
const isChildInPopover = isChildInPanel || isChildInButton;
|
||||
|
||||
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
|
||||
}, [openPopover, modifierKeyPressed]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${shortcut}`, (event) => {
|
||||
if (!visibleShortcut) return;
|
||||
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
onKeyPress?.();
|
||||
});
|
||||
|
||||
const renderShortcut = () => {
|
||||
if (shortcut === "leftarrow") {
|
||||
return "←";
|
||||
}
|
||||
|
||||
if (shortcut === "rightarrow") {
|
||||
return "→";
|
||||
}
|
||||
|
||||
return shortcut;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={childrenRef}
|
||||
className={clsx(rootClassName, "relative inline-block")}
|
||||
>
|
||||
{children}
|
||||
|
||||
{visibleShortcut ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
shortcutClassName
|
||||
)}
|
||||
>
|
||||
{renderShortcut()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisibleKey;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useImperativeHandle, forwardRef } from "react";
|
||||
import { useBoolean } from "ahooks";
|
||||
import { useRef, useImperativeHandle, forwardRef, KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface AutoResizeTextareaProps {
|
||||
@@ -6,15 +7,17 @@ interface AutoResizeTextareaProps {
|
||||
setInput: (value: string) => void;
|
||||
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
connected: boolean;
|
||||
chatPlaceholder?: string;
|
||||
}
|
||||
|
||||
// Forward ref to allow parent to interact with this component
|
||||
const AutoResizeTextarea = forwardRef<
|
||||
{ reset: () => void; focus: () => void },
|
||||
AutoResizeTextareaProps
|
||||
>(({ input, setInput, handleKeyDown, connected }, ref) => {
|
||||
>(({ input, setInput, handleKeyDown, connected, chatPlaceholder }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [isComposition, { setTrue, setFalse }] = useBoolean();
|
||||
|
||||
// Expose methods to the parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -26,6 +29,12 @@ const AutoResizeTextarea = forwardRef<
|
||||
},
|
||||
}));
|
||||
|
||||
const handleKeyPress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (isComposition) return;
|
||||
|
||||
handleKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -34,11 +43,17 @@ const AutoResizeTextarea = forwardRef<
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="text-base flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
|
||||
placeholder={connected ? t('search.textarea.placeholder') : ""}
|
||||
aria-label={t('search.textarea.ariaLabel')}
|
||||
placeholder={
|
||||
connected ? chatPlaceholder || t("search.textarea.placeholder") : ""
|
||||
}
|
||||
aria-label={t("search.textarea.ariaLabel")}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown?.(e)}
|
||||
onKeyDown={handleKeyPress}
|
||||
onCompositionStart={setTrue}
|
||||
onCompositionEnd={() => {
|
||||
setTimeout(setFalse, 0);
|
||||
}}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: "none", // Prevent manual resize
|
||||
|
||||
56
src/components/Search/Calculator.tsx
Normal file
56
src/components/Search/Calculator.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ChevronsRight } from "lucide-react";
|
||||
import { FC } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { copyToClipboard } from "@/utils";
|
||||
|
||||
interface CalculatorProps {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const Calculator: FC<CalculatorProps> = (props) => {
|
||||
const { item, isSelected } = props;
|
||||
const {
|
||||
payload: { query, result },
|
||||
} = item;
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const renderItem = (result: string, description: string) => {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-1 items-center justify-center h-[90px] overflow-hidden rounded-[4px] border border-transparent transition bg-[#F8F8F8] dark:bg-[#141414]">
|
||||
<div className="w-[90%] text-xl text-[#333] dark:text-[#d8d8d8] truncate text-center">
|
||||
{result}
|
||||
</div>
|
||||
<div className="w-[90%] text-xs text-[#999] dark:text-[#666] truncate text-center">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-2 w-full rounded-lg transition",
|
||||
{
|
||||
"bg-[#EDEDED] dark:bg-[#202126]": isSelected,
|
||||
}
|
||||
)}
|
||||
onDoubleClick={() => {
|
||||
copyToClipboard(result.value);
|
||||
}}
|
||||
>
|
||||
{renderItem(query.value, t(`calculator.${query.type}`))}
|
||||
|
||||
<ChevronsRight className="text-[#999999] size-5" />
|
||||
|
||||
{renderItem(
|
||||
result.value,
|
||||
i18n.language === "zh" ? result.toZh : result.toEn
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calculator;
|
||||
@@ -1,79 +1,125 @@
|
||||
import {
|
||||
useClickAway,
|
||||
useCreation,
|
||||
useEventListener,
|
||||
useReactive,
|
||||
} from "ahooks";
|
||||
import { useClickAway, useCreation, useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { isNil } from "lodash-es";
|
||||
import { Link, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { cloneElement, useEffect, useRef } from "react";
|
||||
import { isNil, lowerCase, noop } from "lodash-es";
|
||||
import { Copy, Link, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { cloneElement, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { CONTEXT_MENU_PANEL_ID } from "@/constants";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { Input } from "@headlessui/react";
|
||||
import VisibleKey from "../Common/VisibleKey";
|
||||
|
||||
interface State {
|
||||
activeMenuIndex: number;
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
hideCoco: () => Promise<void>;
|
||||
hideCoco?: () => void;
|
||||
}
|
||||
|
||||
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const state = useReactive<State>({
|
||||
activeMenuIndex: 0,
|
||||
});
|
||||
const visibleContextMenu = useSearchStore((state) => {
|
||||
return state.visibleContextMenu;
|
||||
});
|
||||
|
||||
const setVisibleContextMenu = useSearchStore((state) => {
|
||||
return state.setVisibleContextMenu;
|
||||
});
|
||||
|
||||
const setOpenPopover = useShortcutsStore((state) => state.setOpenPopover);
|
||||
const selectedSearchContent = useSearchStore((state) => {
|
||||
return state.selectedSearchContent;
|
||||
});
|
||||
const [searchMenus, setSearchMenus] = useState<typeof menus>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const title = useCreation(() => {
|
||||
if (selectedSearchContent?.id === "Calculator") {
|
||||
return t("search.contextMenu.title.calculator");
|
||||
}
|
||||
|
||||
return selectedSearchContent?.title;
|
||||
}, [selectedSearchContent]);
|
||||
|
||||
const menus = useCreation(() => {
|
||||
if (isNil(selectedSearchContent)) return [];
|
||||
|
||||
return [
|
||||
const { url, category, payload } = selectedSearchContent;
|
||||
const { query, result } = payload ?? {};
|
||||
|
||||
const menus = [
|
||||
{
|
||||
name: "search.contextMenu.open",
|
||||
name: t("search.contextMenu.open"),
|
||||
icon: <SquareArrowOutUpRight />,
|
||||
keys: isMac ? ["↩︎"] : ["Enter"],
|
||||
shortcut: "enter",
|
||||
hide: category === "Calculator",
|
||||
clickEvent: () => {
|
||||
OpenURLWithBrowser(selectedSearchContent?.url);
|
||||
OpenURLWithBrowser(url);
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
|
||||
hideCoco();
|
||||
hideCoco && hideCoco();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search.contextMenu.copyLink",
|
||||
name: t("search.contextMenu.copyLink"),
|
||||
icon: <Link />,
|
||||
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
|
||||
shortcut: isMac ? "meta.l" : "ctrl.l",
|
||||
clickEvent: () => {
|
||||
copyToClipboard(selectedSearchContent?.url);
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
hide: category === "Calculator",
|
||||
clickEvent() {
|
||||
copyToClipboard(url);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("search.contextMenu.copyAnswer"),
|
||||
icon: <Copy />,
|
||||
keys: isMac ? ["↩︎"] : ["Enter"],
|
||||
shortcut: "enter",
|
||||
hide: category !== "Calculator",
|
||||
clickEvent() {
|
||||
copyToClipboard(result.value);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("search.contextMenu.copyUppercaseAnswer"),
|
||||
icon: <Copy />,
|
||||
keys: isMac ? ["⌘", "↩︎"] : ["Ctrl", "Enter"],
|
||||
shortcut: "meta.enter",
|
||||
hide: category !== "Calculator",
|
||||
clickEvent() {
|
||||
copyToClipboard(i18n.language === "zh" ? result.toZh : result.toEn);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: t("search.contextMenu.copyQuestionAndAnswer"),
|
||||
icon: <Copy />,
|
||||
keys: isMac ? ["⌘", "L"] : ["Ctrl", "L"],
|
||||
shortcut: "meta.l",
|
||||
hide: category !== "Calculator",
|
||||
clickEvent() {
|
||||
copyToClipboard(`${query.value} = ${result.value}`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterMenus = menus.filter((item) => !item.hide);
|
||||
|
||||
setSearchMenus(filterMenus);
|
||||
|
||||
return filterMenus;
|
||||
}, [selectedSearchContent]);
|
||||
|
||||
const state = useReactive<State>({
|
||||
activeMenuIndex: 0,
|
||||
});
|
||||
const shortcuts = useCreation(() => {
|
||||
return menus.map((item) => item.shortcut);
|
||||
}, [menus]);
|
||||
|
||||
useEffect(() => {
|
||||
state.activeMenuIndex = 0;
|
||||
@@ -111,23 +157,30 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
}
|
||||
});
|
||||
|
||||
useOSKeyPress(
|
||||
menus.map((item) => item.shortcut),
|
||||
(_, key) => {
|
||||
if (!visibleContextMenu) return;
|
||||
|
||||
const item = menus.find((item) => item.shortcut === key);
|
||||
|
||||
item?.clickEvent();
|
||||
}
|
||||
);
|
||||
|
||||
useEventListener("keydown", (event) => {
|
||||
useOSKeyPress(shortcuts, (_, key) => {
|
||||
if (!visibleContextMenu) return;
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
let matched;
|
||||
|
||||
if (key === "enter") {
|
||||
matched = menus.find((_, index) => index === state.activeMenuIndex);
|
||||
} else {
|
||||
matched = menus.find((item) => item.shortcut === key);
|
||||
}
|
||||
|
||||
handleClick(matched?.clickEvent);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setOpenPopover(visibleContextMenu);
|
||||
}, [visibleContextMenu]);
|
||||
|
||||
const handleClick = (click = noop) => {
|
||||
click?.();
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleContextMenu && (
|
||||
@@ -138,41 +191,44 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
}}
|
||||
></div>
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
id={visibleContextMenu ? CONTEXT_MENU_PANEL_ID : ""}
|
||||
className={clsx(
|
||||
"fixed bottom-[40px] right-[8px] min-w-[280px] scale-0 transition origin-bottom-right text-sm p-1 bg-white dark:bg-[#202126] rounded-lg shadow-xs border border-gray-200 dark:border-gray-700",
|
||||
"absolute bottom-[50px] right-[18px] w-[300px] flex flex-col gap-2 scale-0 transition origin-bottom-right text-sm p-3 pb-0 bg-white dark:bg-black rounded-lg shadow-xs border border-[#EDEDED] dark:border-[#272828] shadow-lg dark:shadow-white/15",
|
||||
{
|
||||
"!scale-100": visibleContextMenu,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ul className="flex flex-col">
|
||||
{menus.map((item, index) => {
|
||||
<div className="text-[#999] dark:text-[#666] truncate">{title}</div>
|
||||
|
||||
<ul className="flex flex-col -mx-2">
|
||||
{searchMenus.map((item, index) => {
|
||||
const { name, icon, keys, clickEvent } = item;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
"flex justify-between items-center px-3 py-2 rounded-lg cursor-pointer",
|
||||
"flex justify-between items-center gap-2 px-2 py-2 rounded-lg cursor-pointer",
|
||||
{
|
||||
"bg-black/5 dark:bg-white/5":
|
||||
"bg-[#EDEDED] dark:bg-[#202126]":
|
||||
index === state.activeMenuIndex,
|
||||
}
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
state.activeMenuIndex = index;
|
||||
}}
|
||||
onClick={clickEvent}
|
||||
onClick={() => handleClick(clickEvent)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-black/80 dark:text-white/80">
|
||||
{cloneElement(icon, { className: "size-4" })}
|
||||
|
||||
<span>{t(name)}</span>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-[4px] text-black/60 dark:text-white/60">
|
||||
@@ -180,7 +236,7 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
<kbd
|
||||
key={key}
|
||||
className={clsx(
|
||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-black/10 dark:border-white/10",
|
||||
"flex justify-center items-center font-sans h-[20px] min-w-[20px] text-[10px] rounded-md border border-[#EDEDED] dark:border-white/10 bg-white dark:bg-[#202126]",
|
||||
{
|
||||
"px-1": key.length > 1,
|
||||
}
|
||||
@@ -194,6 +250,34 @@ const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="-mx-3 p-2 border-t border-[#E6E6E6] dark:border-[#262626]">
|
||||
{visibleContextMenu && (
|
||||
<VisibleKey
|
||||
shortcut="F"
|
||||
shortcutClassName="left-3"
|
||||
onKeyPress={() => {
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
autoFocus
|
||||
placeholder={t("search.contextMenu.search")}
|
||||
className="w-full bg-transparent"
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
|
||||
const searchMenus = menus.filter((item) => {
|
||||
return lowerCase(item.name).includes(lowerCase(value));
|
||||
});
|
||||
|
||||
setSearchMenus(searchMenus);
|
||||
}}
|
||||
/>
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,93 +3,248 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { formatter } from "@/utils/index";
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import defaultThumbnail from "@/assets/coconut-tree.png";
|
||||
import ItemIcon from "@/components/Common/Icons/ItemIcon";
|
||||
import { RichCategories } from "./ListRight";
|
||||
|
||||
interface DocumentDetailProps {
|
||||
document: any;
|
||||
}
|
||||
|
||||
// Add a reusable DetailItem component
|
||||
interface DetailItemProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DetailItem: React.FC<DetailItemProps> = ({ label, value, icon }) => (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5 border-t border-[rgba(238,240,243,1)] dark:border-[#272626] pt-2.5">
|
||||
<div className="text-[rgba(153,153,153,1)] dark:text-[#666] min-w-[80px]">{label}</div>
|
||||
<div
|
||||
className="text-[rgba(51,51,51,1);] dark:text-[#D8D8D8] flex justify-end text-right flex-1 truncate group relative"
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{icon}
|
||||
<div className="truncate">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main component implementation
|
||||
export const DocumentDetail: React.FC<DocumentDetailProps> = ({ document }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const truncateUrl = (url: string) => {
|
||||
if (url.length <= 40) return url;
|
||||
return `${url.slice(0, 20)}...${url.slice(-20)}`;
|
||||
};
|
||||
|
||||
// categories: null
|
||||
|
||||
// category: null
|
||||
|
||||
// content: null
|
||||
|
||||
// cover: null
|
||||
|
||||
// created: null
|
||||
|
||||
// icon: "http://localhost:9000/assets/icons/connector/hugo_site/web.png"
|
||||
|
||||
// id: "31a8db836fe503d8f1d3ce2ea7c2fe6d"
|
||||
|
||||
// lang: null
|
||||
|
||||
// last_updated_by: null
|
||||
|
||||
// metadata: null
|
||||
|
||||
// owner: null
|
||||
|
||||
// payload: null
|
||||
|
||||
// rich_categories: null
|
||||
|
||||
// size: null
|
||||
|
||||
// source: {type: "connector", name: "INFINI Labs 官网", id: "cu4vj5o2sdb34a5pcbfg", icon: "http://localhost:9000/assets/icons/connector/hugo_site/icon.png"}
|
||||
|
||||
// subcategory: null
|
||||
|
||||
// summary: null
|
||||
|
||||
// tags: null
|
||||
|
||||
// thumbnail: null
|
||||
|
||||
// title: "dump_hash"
|
||||
|
||||
// type: "web_page"
|
||||
|
||||
// updated: null
|
||||
|
||||
// url: "https://infi
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="font-normal text-xs text-[#666] dark:text-[#999] mb-2">
|
||||
{t('search.document.details')}
|
||||
<div className="p-3">
|
||||
{/* <div className="font-normal text-xs text-[#666] dark:text-[#999] mb-2">
|
||||
{t("search.document.details")}
|
||||
</div> */}
|
||||
<div className="text-xs font-normal text-[rgba(51,51,51,1)] dark:text-[#D8D8D8]">
|
||||
{document?.title || "-"}
|
||||
</div>
|
||||
|
||||
{/* <div className="mb-4">
|
||||
<iframe
|
||||
src={document?.metadata?.web_view_link}
|
||||
style={{ width: "100%", height: "500px" }}
|
||||
title="Text Preview"
|
||||
<div className="py-4">
|
||||
{/* Document Thumbnail */}
|
||||
<div className="mb-4 h-[140px] rounded-lg bg-[rgba(243,244,246,1)] dark:bg-[#202126] flex justify-center items-center">
|
||||
{document.thumbnail ? (
|
||||
<img
|
||||
src={document.thumbnail}
|
||||
alt="thumbnail"
|
||||
className="max-w-[200px] max-h-[120px] object-contain"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.src = defaultThumbnail;
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ItemIcon item={document} className="w-16 h-16" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Summary */}
|
||||
{document?.summary && (
|
||||
<div className="mb-4 text-xs text-[rgba(153,153,153,1)] dark:text-[#D8D8D8] whitespace-pre-wrap break-words">
|
||||
{document.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Tags */}
|
||||
{document?.tags && document.tags.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-1">
|
||||
{document.tags.map((tag: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DetailItem
|
||||
label={t("search.document.source")}
|
||||
value={document?.source?.name || "-"}
|
||||
icon={<TypeIcon item={document} className="w-4 h-4 mr-1" />}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{/* <img
|
||||
src="https://images.unsplash.com/photo-1664575602276-acd073f104c1"
|
||||
alt="Document preview"
|
||||
className="w-full aspect-video object-cover rounded-xl shadow-md"
|
||||
/> */}
|
||||
{/* Rich Categories */}
|
||||
{document?.rich_categories && (
|
||||
<DetailItem
|
||||
label={t("search.document.richCategories")}
|
||||
value={
|
||||
<div className="min-w-[160px] flex items-center justify-end w-full text-[12px] relative">
|
||||
<RichCategories item={document} isSelected={false} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="py-4 mt-4">
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.name')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-60 break-words">
|
||||
{document?.title || "-"}
|
||||
</div>
|
||||
</div>
|
||||
{/* Document URL */}
|
||||
{document?.url && (
|
||||
<DetailItem
|
||||
label={t("search.document.url")}
|
||||
value={
|
||||
<a
|
||||
href={document.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 whitespace-nowrap"
|
||||
title={document.url}
|
||||
>
|
||||
{truncateUrl(document.url)}
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.source')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] flex justify-end text-right w-56 break-words">
|
||||
<TypeIcon item={document} className="w-4 h-4 mr-1" />
|
||||
{document?.source?.name || "-"}
|
||||
</div>
|
||||
</div>
|
||||
{/* Document Identifier */}
|
||||
{document?.id && (
|
||||
<DetailItem label={t("search.document.id")} value={document.id} />
|
||||
)}
|
||||
|
||||
{/* Creation Time */}
|
||||
{document?.created && (
|
||||
<DetailItem
|
||||
label={t("search.document.createdAt")}
|
||||
value={document.created}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Classification */}
|
||||
{document?.category && (
|
||||
<DetailItem
|
||||
label={t("search.document.category")}
|
||||
value={document.category}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Subcategory */}
|
||||
{document?.subcategory && (
|
||||
<DetailItem
|
||||
label={t("search.document.subcategory")}
|
||||
value={document.subcategory}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Language */}
|
||||
{document?.lang && (
|
||||
<DetailItem
|
||||
label={t("search.document.language")}
|
||||
value={document.lang.toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last Update Time */}
|
||||
{document?.updated && (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.updatedAt')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?.updated || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<DetailItem
|
||||
label={t("search.document.updatedAt")}
|
||||
value={document?.updated || "-"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Last Modified By */}
|
||||
{document?.last_updated_by?.user?.username && (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.updatedBy')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?.last_updated_by?.user?.username || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<DetailItem
|
||||
label={t("search.document.updatedBy")}
|
||||
value={document?.last_updated_by?.user?.username || "-"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Owner */}
|
||||
{document?.owner?.username && (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.createdBy')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?.owner?.username || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<DetailItem
|
||||
label={t("search.document.createdBy")}
|
||||
value={document?.owner?.username || "-"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Type */}
|
||||
{document?.type && (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.type')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{document?.type || "-"}
|
||||
</div>
|
||||
</div>
|
||||
<DetailItem
|
||||
label={t("search.document.type")}
|
||||
value={document?.type || "-"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Size */}
|
||||
{document?.size && (
|
||||
<div className="flex justify-between flex-wrap font-normal text-xs mb-2.5">
|
||||
<div className="text-[#666]">{t('search.document.size')}</div>
|
||||
<div className="text-[#333] dark:text-[#D8D8D8] text-right w-56 break-words">
|
||||
{formatter.bytes(document?.size || 0)}
|
||||
</div>
|
||||
</div>
|
||||
<DetailItem
|
||||
label={t("search.document.size")}
|
||||
value={formatter.bytes(document?.size || 0)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useInfiniteScroll } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FixedSizeList } from "react-window";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { SearchHeader } from "./SearchHeader";
|
||||
@@ -9,24 +8,22 @@ import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
import { Get } from "@/api/axiosRequest";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface DocumentListProps {
|
||||
onSelectDocument: (id: string) => void;
|
||||
getDocDetail: (detail: any) => void;
|
||||
getDocDetail: (detail: Record<string, any>) => void;
|
||||
input: string;
|
||||
isChatMode: boolean;
|
||||
selectedId?: string;
|
||||
viewMode: "detail" | "list";
|
||||
setViewMode: (mode: "detail" | "list") => void;
|
||||
queryDocuments: (
|
||||
from: number,
|
||||
size: number,
|
||||
queryStrings: any
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const ITEM_HEIGHT = 48; // SearchListItem height(padding + content)
|
||||
|
||||
export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
input,
|
||||
@@ -34,25 +31,25 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
isChatMode,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
queryDocuments,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const isTauri = useAppStore((state) => state.isTauri);
|
||||
const queryTimeout = useConnectStore((state) => state.querySourceTimeout);
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [isKeyboardMode, setIsKeyboardMode] = useState(false);
|
||||
const listRef = useRef<FixedSizeList>(null);
|
||||
|
||||
const { data, loading } = useInfiniteScroll(
|
||||
async (d) => {
|
||||
const from = d?.list?.length || 0;
|
||||
|
||||
let queryStrings: any = {
|
||||
query: input,
|
||||
datasource: sourceData?.source?.id,
|
||||
querysource: sourceData?.querySource?.id,
|
||||
};
|
||||
|
||||
if (sourceData?.rich_categories) {
|
||||
@@ -62,42 +59,67 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryDocuments(from, PAGE_SIZE, queryStrings);
|
||||
const list = response?.hits || [];
|
||||
const total = response?.total_hits || 0;
|
||||
let response: any;
|
||||
if (isTauri) {
|
||||
response = await platformAdapter.commands("query_coco_fusion", {
|
||||
from: from,
|
||||
size: PAGE_SIZE,
|
||||
queryStrings: queryStrings,
|
||||
queryTimeout,
|
||||
});
|
||||
} else {
|
||||
let url = `/query/_search?query=${queryStrings.query}&datasource=${queryStrings.datasource}&from=${from}&size=${PAGE_SIZE}`;
|
||||
if (queryStrings?.rich_categories) {
|
||||
url = `/query/_search?query=${queryStrings.query}&rich_category=${queryStrings.rich_category}&from=${from}&size=${PAGE_SIZE}`;
|
||||
}
|
||||
const [error, res]: any = await Get(url);
|
||||
|
||||
// console.log("docs:", list, total);
|
||||
if (error) {
|
||||
console.error("_search", error);
|
||||
response = { hits: [], total: 0 };
|
||||
} else {
|
||||
const hits =
|
||||
res?.hits?.hits?.map((hit: any) => ({
|
||||
document: {
|
||||
...hit._source,
|
||||
},
|
||||
score: hit._score || 0,
|
||||
source: hit._source.source || null,
|
||||
})) || [];
|
||||
const total = res?.hits?.total?.value || 0;
|
||||
|
||||
setTotal(total);
|
||||
|
||||
return {
|
||||
list: list,
|
||||
hasMore: list.length === PAGE_SIZE,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch documents:", error);
|
||||
return {
|
||||
list: d?.list || [],
|
||||
hasMore: false,
|
||||
};
|
||||
response = {
|
||||
hits: hits,
|
||||
total_hits: total,
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log("_docs", from, queryStrings, response);
|
||||
const list = response?.hits || [];
|
||||
const total = response?.total_hits || 0;
|
||||
setTotal(total);
|
||||
|
||||
return {
|
||||
list: list,
|
||||
hasMore: list.length === PAGE_SIZE && from + list.length < total,
|
||||
};
|
||||
},
|
||||
{
|
||||
target: containerRef,
|
||||
isNoMore: (d) => !d?.hasMore,
|
||||
reloadDeps: [input, JSON.stringify(sourceData)],
|
||||
onFinally: (data) => onFinally(data, containerRef),
|
||||
reloadDeps: [input?.trim(), JSON.stringify(sourceData)],
|
||||
onFinally: (data) => {
|
||||
if (data?.page === 1) return;
|
||||
if (selectedItem === null) return;
|
||||
setSelectedItem(null);
|
||||
itemRefs.current[selectedItem]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const onFinally = (data: any, _ref: any) => {
|
||||
if (data?.page === 1) return;
|
||||
if (selectedItem === null) return;
|
||||
|
||||
listRef.current?.scrollToItem(selectedItem, "smart");
|
||||
};
|
||||
|
||||
const onMouseEnter = useCallback(
|
||||
(index: number, item: any) => {
|
||||
if (isKeyboardMode) return;
|
||||
@@ -116,100 +138,65 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
(e: KeyboardEvent) => {
|
||||
if (!data?.list?.length) return;
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const handleArrowKeys = () => {
|
||||
e.preventDefault();
|
||||
setIsKeyboardMode(true);
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedItem((prev) => {
|
||||
const newIndex = prev === null || prev === 0 ? 0 : prev - 1;
|
||||
getDocDetail(data.list[newIndex]?.document);
|
||||
listRef.current?.scrollToItem(newIndex, "smart");
|
||||
return newIndex;
|
||||
});
|
||||
} else {
|
||||
setSelectedItem((prev) => {
|
||||
const newIndex =
|
||||
prev === null
|
||||
? 0
|
||||
: prev === data.list.length - 1
|
||||
? prev
|
||||
: prev + 1;
|
||||
getDocDetail(data.list[newIndex]?.document);
|
||||
listRef.current?.scrollToItem(newIndex, "smart");
|
||||
return newIndex;
|
||||
});
|
||||
}
|
||||
} else if (e.key === metaOrCtrlKey()) {
|
||||
e.preventDefault();
|
||||
}
|
||||
setSelectedItem((prev) => {
|
||||
const isArrowUp = e.key === "ArrowUp";
|
||||
const nextIndex =
|
||||
prev === null
|
||||
? 0
|
||||
: isArrowUp
|
||||
? Math.max(0, prev - 1)
|
||||
: Math.min(data.list.length - 1, prev + 1);
|
||||
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
const item = data?.list?.[selectedItem];
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
getDocDetail(data.list[nextIndex]?.document);
|
||||
itemRefs.current[nextIndex]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
return nextIndex;
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnter = () => {
|
||||
if (selectedItem === null) return;
|
||||
const item = data.list[selectedItem]?.document;
|
||||
item?.url && OpenURLWithBrowser(item.url);
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
handleArrowKeys();
|
||||
break;
|
||||
case metaOrCtrlKey():
|
||||
e.preventDefault();
|
||||
break;
|
||||
case "Enter":
|
||||
handleEnter();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[data, selectedItem, getDocDetail]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (e.movementX !== 0 || e.movementY !== 0) {
|
||||
setIsKeyboardMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (e.movementX !== 0 || e.movementY !== 0) {
|
||||
setIsKeyboardMode(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyDown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem !== null) {
|
||||
listRef.current?.scrollToItem(selectedItem, "smart");
|
||||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
const Row = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const hit = data?.list[index];
|
||||
if (!hit) return null;
|
||||
|
||||
const isSelected = selectedItem === index;
|
||||
const item = hit.document;
|
||||
|
||||
return (
|
||||
<div style={style}>
|
||||
<SearchListItem
|
||||
key={item.id + index}
|
||||
itemRef={(el) => (itemRefs.current[index] = el)}
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
currentIndex={index}
|
||||
onMouseEnter={() => onMouseEnter(index, item)}
|
||||
onItemClick={() => {
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}}
|
||||
showListRight={viewMode === "list"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data, selectedItem, viewMode, onMouseEnter]
|
||||
);
|
||||
}, [handleKeyDown, handleMouseMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -225,36 +212,28 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{data?.list && data.list.length > 0 ? (
|
||||
<div ref={containerRef} style={{ height: "100%" }}>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
height={containerRef.current?.clientHeight || 400}
|
||||
width="100%"
|
||||
itemCount={data?.list.length}
|
||||
itemSize={ITEM_HEIGHT}
|
||||
overscanCount={5}
|
||||
onScroll={({ scrollOffset, scrollUpdateWasRequested }) => {
|
||||
if (!scrollUpdateWasRequested && containerRef.current) {
|
||||
const threshold = 100;
|
||||
const { scrollHeight, clientHeight } = containerRef.current;
|
||||
const remainingScroll =
|
||||
scrollHeight - (scrollOffset + clientHeight);
|
||||
if (
|
||||
remainingScroll <= threshold &&
|
||||
!loading &&
|
||||
data?.hasMore
|
||||
) {
|
||||
data?.loadMore && data.loadMore();
|
||||
}
|
||||
<div
|
||||
className="flex-1 overflow-auto custom-scrollbar pr-0.5"
|
||||
ref={containerRef}
|
||||
>
|
||||
{data?.list && data.list.length > 0 && (
|
||||
<div>
|
||||
{data.list.map((hit, index) => (
|
||||
<SearchListItem
|
||||
key={hit.document.id + index}
|
||||
itemRef={(el) => (itemRefs.current[index] = el)}
|
||||
item={hit.document}
|
||||
isSelected={selectedItem === index}
|
||||
currentIndex={index}
|
||||
onMouseEnter={() => onMouseEnter(index, hit.document)}
|
||||
onItemClick={() =>
|
||||
hit.document?.url && OpenURLWithBrowser(hit.document.url)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{Row}
|
||||
</FixedSizeList>
|
||||
showListRight={viewMode === "list"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -262,7 +241,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data?.list.length === 0 && (
|
||||
{!loading && (!data?.list || data.list.length === 0) && (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, MouseEvent } from "react";
|
||||
import { CircleAlert, Bolt, X, ArrowBigRight } from "lucide-react";
|
||||
import { isNil } from "lodash-es";
|
||||
import { useUnmount } from "ahooks";
|
||||
import { useDebounceFn, useUnmount } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import ThemedIcon from "@/components/Common/Icons/ThemedIcon";
|
||||
@@ -9,7 +10,11 @@ import IconWrapper from "@/components/Common/Icons/IconWrapper";
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils/index";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import Calculator from "./Calculator";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
type ISearchData = Record<string, any[]>;
|
||||
|
||||
@@ -27,12 +32,12 @@ function DropdownList({
|
||||
IsError,
|
||||
isChatMode,
|
||||
}: DropdownListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let globalIndex = 0;
|
||||
const globalItemIndexMap: any[] = [];
|
||||
|
||||
const setSourceData = useSearchStore(
|
||||
(state: { setSourceData: any }) => state.setSourceData
|
||||
);
|
||||
const setSourceData = useSearchStore((state) => state.setSourceData);
|
||||
|
||||
const [showError, setShowError] = useState<boolean>(IsError);
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
@@ -41,11 +46,18 @@ function DropdownList({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const setSelectedSearchContent = useSearchStore((state) => {
|
||||
return state.setSelectedSearchContent;
|
||||
});
|
||||
const setSelectedSearchContent = useSearchStore(
|
||||
(state) => state.setSelectedSearchContent
|
||||
);
|
||||
|
||||
const hideArrowRight = (item: any) => {
|
||||
const categories = ["Calculator"];
|
||||
|
||||
return categories.includes(item.category);
|
||||
};
|
||||
|
||||
useUnmount(() => {
|
||||
setSelectedItem(null);
|
||||
setSelectedSearchContent(void 0);
|
||||
});
|
||||
|
||||
@@ -65,15 +77,19 @@ function DropdownList({
|
||||
}
|
||||
}, [isChatMode]);
|
||||
|
||||
const { run } = useDebounceFn(() => setSelectedItem(0), { wait: 200 });
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedItem(null);
|
||||
|
||||
run();
|
||||
}, [SearchData]);
|
||||
|
||||
const openPopover = useShortcutsStore((state) => state.openPopover);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// console.log(
|
||||
// "handleKeyDown",
|
||||
// e.key,
|
||||
// showIndex,
|
||||
// e.key >= "0" && e.key <= "9" && showIndex
|
||||
// );
|
||||
if (!suggests.length) return;
|
||||
if (!suggests.length || openPopover) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
@@ -99,7 +115,11 @@ function DropdownList({
|
||||
|
||||
if (e.key === "ArrowRight" && selectedItem !== null) {
|
||||
e.preventDefault();
|
||||
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
|
||||
if (hideArrowRight(item)) return;
|
||||
|
||||
goToTwoPage(item);
|
||||
}
|
||||
|
||||
@@ -108,18 +128,24 @@ function DropdownList({
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
} else {
|
||||
copyToClipboard(item?.payload?.result?.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key >= "0" && e.key <= "9" && showIndex) {
|
||||
// console.log(`number ${e.key}`);
|
||||
const item = globalItemIndexMap[parseInt(e.key, 10)];
|
||||
let index = parseInt(e.key, 10);
|
||||
|
||||
index = index === 0 ? 9 : index - 1;
|
||||
|
||||
const item = globalItemIndexMap[index];
|
||||
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}
|
||||
},
|
||||
[suggests, selectedItem, showIndex, globalItemIndexMap]
|
||||
[suggests, selectedItem, showIndex, globalItemIndexMap, openPopover]
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
@@ -132,6 +158,8 @@ function DropdownList({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatMode) return;
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
@@ -154,73 +182,111 @@ function DropdownList({
|
||||
setSourceData(item);
|
||||
}
|
||||
|
||||
const setVisibleContextMenu = useSearchStore(
|
||||
(state) => state.setVisibleContextMenu
|
||||
);
|
||||
|
||||
const handleContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setVisibleContextMenu(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="h-[458px] w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
className="h-full w-full p-2 flex flex-col overflow-y-auto custom-scrollbar focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
{showError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-[#333] p-2">
|
||||
<CircleAlert className="text-[#FF0000] w-[14px] h-[14px]" />
|
||||
Coco server is unavailable, only local results and available services
|
||||
are displayed.
|
||||
<Bolt className="text-[#000] w-[14px] h-[14px] cursor-pointer" />
|
||||
{showError && (
|
||||
<div className="flex items-center gap-2 text-sm text-[#333] dark:text-[#666] p-2">
|
||||
<CircleAlert className="text-[#FF0000] size-3" />
|
||||
{t("search.list.failures")}
|
||||
<Bolt
|
||||
className="dark:text-white size-3 cursor-pointer"
|
||||
onClick={() => {
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
}}
|
||||
/>
|
||||
<X
|
||||
className="text-[#666] w-[16px] h-[16px] cursor-pointer"
|
||||
className="text-[#666] size-4 cursor-pointer"
|
||||
onClick={() => setShowError(false)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.entries(SearchData).map(([sourceName, items]) => (
|
||||
<div key={sourceName}>
|
||||
{Object.entries(SearchData).length < 5 ? (
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
||||
<TypeIcon item={items[0]?.document} className="w-4 h-4" />
|
||||
{sourceName} - {items[0]?.source.name}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
<IconWrapper
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(items[0]?.document);
|
||||
}}
|
||||
>
|
||||
<ThemedIcon component={ArrowBigRight} className="w-4 h-4" />
|
||||
</IconWrapper>
|
||||
{showIndex && sourceName === selectedName ? (
|
||||
<div className={`bg-[#ccc] dark:bg-[#6B6B6B] `}>→</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
{Object.entries(SearchData).map(([sourceName, items]) => {
|
||||
const showHeader = Object.entries(SearchData).length < 5;
|
||||
|
||||
{items.map((hit: any, index: number) => {
|
||||
const isSelected = selectedItem === globalIndex;
|
||||
const currentIndex = globalIndex;
|
||||
const item = hit.document;
|
||||
globalItemIndexMap.push(item);
|
||||
globalIndex++;
|
||||
return (
|
||||
<SearchListItem
|
||||
key={item.id + index}
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
currentIndex={currentIndex}
|
||||
showIndex={showIndex}
|
||||
onMouseEnter={() => setSelectedItem(currentIndex)}
|
||||
onItemClick={() => {
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}}
|
||||
goToTwoPage={goToTwoPage}
|
||||
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div key={sourceName}>
|
||||
{showHeader && (
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666] flex items-center gap-2.5 relative">
|
||||
<TypeIcon item={items[0]?.document} className="w-4 h-4" />
|
||||
{sourceName} - {items[0]?.source.name}
|
||||
<div className="flex-1 border-b border-b-[#e6e6e6] dark:border-b-[#272626]"></div>
|
||||
{!hideArrowRight({ category: sourceName }) && (
|
||||
<>
|
||||
<IconWrapper
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage(items[0]?.document);
|
||||
}}
|
||||
>
|
||||
<ThemedIcon
|
||||
component={ArrowBigRight}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</IconWrapper>
|
||||
{showIndex && sourceName === selectedName && (
|
||||
<div className="absolute top-1 right-4">
|
||||
<VisibleKey shortcut="→" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.map((hit: any) => {
|
||||
const isSelected = selectedItem === globalIndex;
|
||||
const currentIndex = globalIndex;
|
||||
const item = hit.document;
|
||||
globalItemIndexMap.push(item);
|
||||
globalIndex++;
|
||||
|
||||
return (
|
||||
<div key={item.id} onContextMenu={handleContextMenu}>
|
||||
{hideArrowRight(item) ? (
|
||||
<div
|
||||
ref={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
onMouseEnter={() => setSelectedItem(currentIndex)}
|
||||
>
|
||||
<Calculator item={item} isSelected={isSelected} />
|
||||
</div>
|
||||
) : (
|
||||
<SearchListItem
|
||||
item={item}
|
||||
isSelected={isSelected}
|
||||
currentIndex={currentIndex}
|
||||
showIndex={showIndex}
|
||||
onMouseEnter={() => setSelectedItem(currentIndex)}
|
||||
onItemClick={() => {
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}}
|
||||
goToTwoPage={() => goToTwoPage(item)}
|
||||
itemRef={(el) => (itemRefs.current[currentIndex] = el)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import PinOffIcon from "@/icons/PinOff";
|
||||
import PinIcon from "@/icons/Pin";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
|
||||
interface FooterProps {
|
||||
openSetting: () => void;
|
||||
setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function Footer({
|
||||
openSetting,
|
||||
setWindowAlwaysOnTop,
|
||||
}: FooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
const setVisible = useUpdateStore((state) => state.setVisible);
|
||||
const updateInfo = useUpdateStore((state) => state.updateInfo);
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await setWindowAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
setIsPinned(isPinned);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="px-4 z-999 mx-[1px] h-8 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">
|
||||
<div className="flex items-center space-x-2">
|
||||
{sourceData?.source?.name ? (
|
||||
<TypeIcon item={sourceData} className="w-4 h-4" />
|
||||
) : (
|
||||
<img
|
||||
src={logoImg}
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={openSetting}
|
||||
alt={t("search.footer.logoAlt")}
|
||||
/>
|
||||
)}
|
||||
<div className="relative text-xs text-gray-500 dark:text-gray-400">
|
||||
{updateInfo?.available ? (
|
||||
<div className="cursor-pointer" onClick={() => setVisible(true)}>
|
||||
<span>{t("search.footer.updateAvailable")}</span>
|
||||
<span className="absolute top-0 -right-2 size-1.5 bg-[#FF3434] rounded-full"></span>
|
||||
</div>
|
||||
) : (
|
||||
sourceData?.source?.name ||
|
||||
t("search.footer.version", {
|
||||
version: process.env.VERSION || "v1.0.0",
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": updateInfo?.available,
|
||||
})}
|
||||
>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-xs">
|
||||
<span className="mr-1.5">{t("search.footer.select")}:</span>
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
{isMac ? (
|
||||
<Command className="w-3 h-3" />
|
||||
) : (
|
||||
<span className="h-3 leading-3 inline-flex items-center text-xs">
|
||||
Ctrl
|
||||
</span>
|
||||
)}
|
||||
</kbd>
|
||||
+
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
<ArrowDown01 className="w-3 h-3" />
|
||||
</kbd>
|
||||
</div>
|
||||
<div className="flex items-center text-[#666] dark:text-[#666] text-xs">
|
||||
<span className="mr-1.5">{t("search.footer.open")}: </span>
|
||||
<kbd className="coco-modal-footer-commands-key pr-1">
|
||||
<CornerDownLeft className="w-3 h-3" />
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ArrowBigLeft, Search, Send, Brain } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useBoolean, useKeyPress } from "ahooks";
|
||||
|
||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||
@@ -11,13 +12,14 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import SearchPopover from "./SearchPopover";
|
||||
import MCPPopover from "./MCPPopover";
|
||||
// import AudioRecording from "../AudioRecording";
|
||||
import { hide_coco } from "@/commands";
|
||||
import { DataSource } from "@/types/commands";
|
||||
// import InputExtra from "./InputExtra";
|
||||
// import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useKeyPress } from "ahooks";
|
||||
import Copyright from "@/components/Common/Copyright";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
@@ -32,8 +34,25 @@ interface ChatInputProps {
|
||||
setIsSearchActive: () => void;
|
||||
isDeepThinkActive: boolean;
|
||||
setIsDeepThinkActive: () => void;
|
||||
isMCPActive: boolean;
|
||||
setIsMCPActive: () => void;
|
||||
isChatPage?: boolean;
|
||||
getDataSourcesByServer: (serverId: string) => Promise<DataSource[]>;
|
||||
getDataSourcesByServer: (
|
||||
serverId: string,
|
||||
options?: {
|
||||
from?: number;
|
||||
size?: number;
|
||||
query?: string;
|
||||
}
|
||||
) => Promise<DataSource[]>;
|
||||
getMCPByServer: (
|
||||
serverId: string,
|
||||
options?: {
|
||||
from?: number;
|
||||
size?: number;
|
||||
query?: string;
|
||||
}
|
||||
) => Promise<DataSource[]>;
|
||||
setupWindowFocusListener: (callback: () => void) => Promise<() => void>;
|
||||
checkScreenPermission: () => Promise<boolean>;
|
||||
requestScreenPermission: () => void;
|
||||
@@ -46,6 +65,10 @@ interface ChatInputProps {
|
||||
}) => Promise<string | string[] | null>;
|
||||
getFileMetadata: (path: string) => Promise<any>;
|
||||
getFileIcon: (path: string, size: number) => Promise<string>;
|
||||
hideCoco?: () => void;
|
||||
hasModules?: string[];
|
||||
searchPlaceholder?: string;
|
||||
chatPlaceholder?: string;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -61,43 +84,31 @@ export default function ChatInput({
|
||||
setIsSearchActive,
|
||||
isDeepThinkActive,
|
||||
setIsDeepThinkActive,
|
||||
isMCPActive,
|
||||
setIsMCPActive,
|
||||
isChatPage = false,
|
||||
getDataSourcesByServer,
|
||||
getMCPByServer,
|
||||
setupWindowFocusListener,
|
||||
}: // checkScreenPermission,
|
||||
// requestScreenPermission,
|
||||
// getScreenMonitors,
|
||||
// getScreenWindows,
|
||||
// captureMonitorScreenshot,
|
||||
// captureWindowScreenshot,
|
||||
// openFileDialog,
|
||||
// getFileMetadata,
|
||||
// getFileIcon,
|
||||
ChatInputProps) {
|
||||
hasModules = [],
|
||||
searchPlaceholder,
|
||||
chatPlaceholder,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showTooltip = useAppStore(
|
||||
(state: { showTooltip: boolean }) => state.showTooltip
|
||||
);
|
||||
const currentAssistant = useConnectStore((state) => state.currentAssistant);
|
||||
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const showTooltip = useAppStore((state) => state.showTooltip);
|
||||
|
||||
const sourceData = useSearchStore(
|
||||
(state: { sourceData: any }) => state.sourceData
|
||||
);
|
||||
const setSourceData = useSearchStore(
|
||||
(state: { setSourceData: any }) => state.setSourceData
|
||||
);
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
const setSourceData = useSearchStore((state) => state.setSourceData);
|
||||
|
||||
// const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||
const returnToInput = useShortcutsStore((state) => state.returnToInput);
|
||||
const deepThinking = useShortcutsStore((state) => state.deepThinking);
|
||||
const [isComposition, { setTrue, setFalse }] = useBoolean();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -128,13 +139,15 @@ ChatInputProps) {
|
||||
}
|
||||
}, [reconnectCountdown, connected]);
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const [_isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const setModifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.setModifierKeyPressed;
|
||||
});
|
||||
const setBlurred = useAppStore((state) => state.setBlurred);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
setBlurred(false);
|
||||
setIsCommandPressed(false);
|
||||
setModifierKeyPressed(false);
|
||||
};
|
||||
@@ -156,6 +169,7 @@ ChatInputProps) {
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
console.log("handleSubmit", trimmedValue, disabled);
|
||||
if (trimmedValue && !disabled) {
|
||||
changeInput("");
|
||||
onSend(trimmedValue);
|
||||
@@ -164,25 +178,14 @@ ChatInputProps) {
|
||||
|
||||
const pressedKeys = new Set<string>();
|
||||
|
||||
const handleEscapeKey = useCallback(() => {
|
||||
if (inputValue) {
|
||||
changeInput("");
|
||||
} else if (!isPinned) {
|
||||
hide_coco();
|
||||
}
|
||||
}, [inputValue, isPinned]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus);
|
||||
|
||||
const visibleContextMenu = useSearchStore((state) => {
|
||||
return state.visibleContextMenu;
|
||||
});
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// console.log("handleKeyDown", e.code, e.key);
|
||||
|
||||
if (e.key === "Escape") {
|
||||
handleEscapeKey();
|
||||
return;
|
||||
}
|
||||
|
||||
pressedKeys.add(e.key);
|
||||
|
||||
if (e.key === metaOrCtrlKey()) {
|
||||
@@ -230,6 +233,7 @@ ChatInputProps) {
|
||||
setIsCommandPressed,
|
||||
disabledChange,
|
||||
curChatEnd,
|
||||
visibleContextMenu,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -272,10 +276,12 @@ ChatInputProps) {
|
||||
setIsDeepThinkActive();
|
||||
};
|
||||
|
||||
const source = currentAssistant?._source
|
||||
|
||||
return (
|
||||
<div className={`w-full relative`}>
|
||||
<div
|
||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative overflow-hidden`}
|
||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded-md transition-all relative overflow-hidden`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
|
||||
{!isChatMode && !sourceData ? (
|
||||
@@ -295,12 +301,13 @@ ChatInputProps) {
|
||||
changeInput(value);
|
||||
}}
|
||||
connected={connected}
|
||||
handleKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
handleKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
chatPlaceholder={chatPlaceholder}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -311,29 +318,40 @@ ChatInputProps) {
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="text-base font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
|
||||
placeholder={t("search.input.searchPlaceholder")}
|
||||
placeholder={
|
||||
searchPlaceholder || t("search.input.searchPlaceholder")
|
||||
}
|
||||
value={inputValue}
|
||||
onCompositionStart={setTrue}
|
||||
onCompositionEnd={() => {
|
||||
setTimeout(setFalse, 0);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
|
||||
if (isComposition) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onChange={(e) => {
|
||||
onSend(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && isCommandPressed && !isChatMode && sourceData ? (
|
||||
<div
|
||||
className={`absolute left-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
|
||||
>
|
||||
←
|
||||
{showTooltip && !isChatMode && sourceData && (
|
||||
<div className="absolute -top-[5px] left-2">
|
||||
<VisibleKey shortcut="←" />
|
||||
</div>
|
||||
) : null}
|
||||
{showTooltip && modifierKeyPressed ? (
|
||||
)}
|
||||
{showTooltip && (
|
||||
<div
|
||||
className={`absolute ${
|
||||
!isChatMode && sourceData ? "left-7" : ""
|
||||
} w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#ededed] dark:shadow-[-6px_0px_6px_2px_#202126]`}
|
||||
className={clsx("absolute -top-[5px] left-2", {
|
||||
"left-8": !isChatMode && sourceData,
|
||||
})}
|
||||
>
|
||||
{returnToInput}
|
||||
<VisibleKey shortcut={returnToInput} />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <AudioRecording
|
||||
@@ -378,27 +396,35 @@ ChatInputProps) {
|
||||
</div>
|
||||
) : null} */}
|
||||
|
||||
{showTooltip && isChatMode && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute right-3 w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
|
||||
>
|
||||
↩︎
|
||||
{showTooltip && isChatMode && (
|
||||
<div className="absolute top-[2px] right-[18px]">
|
||||
<VisibleKey shortcut="↩︎" />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{!connected && isChatMode ? (
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4">
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-[rgba(238,238,238,0.98)] dark:bg-[rgba(32,33,38,0.9)] backdrop-blur-[2px] rounded-md font-normal text-xs text-gray-400 flex items-center gap-4 z-10">
|
||||
{t("search.input.connectionError")}
|
||||
<div
|
||||
className="h-[24px] px-2 bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
|
||||
className="px-1 h-[24px] text-[#0061FF] font-normal text-xs flex items-center justify-center cursor-pointer underline"
|
||||
onClick={() => {
|
||||
reconnect();
|
||||
setReconnectCountdown(10);
|
||||
}}
|
||||
>
|
||||
{reconnectCountdown > 0
|
||||
? `${t("search.input.connecting")}(${reconnectCountdown}s)`
|
||||
: t("search.input.reconnect")}
|
||||
{reconnectCountdown > 0 ? (
|
||||
`${t("search.input.connecting")}(${reconnectCountdown}s)`
|
||||
) : (
|
||||
<VisibleKey
|
||||
shortcut="R"
|
||||
onKeyPress={() => {
|
||||
reconnect();
|
||||
setReconnectCountdown(10);
|
||||
}}
|
||||
>
|
||||
{t("search.input.reconnect")}
|
||||
</VisibleKey>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -409,7 +435,7 @@ ChatInputProps) {
|
||||
className="flex justify-between items-center py-2"
|
||||
>
|
||||
{isChatMode ? (
|
||||
<div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
<div className="flex gap-2 text-[12px] leading-3 text-[#333] dark:text-[#d8d8d8]">
|
||||
{/* {sessionId && (
|
||||
<InputExtra
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
@@ -424,38 +450,60 @@ ChatInputProps) {
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-1 h-6 rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||
}
|
||||
)}
|
||||
onClick={DeepThinkClick}
|
||||
>
|
||||
<Brain
|
||||
className={`size-4 ${
|
||||
isDeepThinkActive
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-[#333] dark:text-white"
|
||||
}`}
|
||||
/>
|
||||
{isDeepThinkActive && (
|
||||
<span
|
||||
className={
|
||||
isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"
|
||||
{source?.type === "deep_think" && source?.config?.visible && (
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center gap-1 py-[3px] pl-1 pr-1.5 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isDeepThinkActive,
|
||||
}
|
||||
>
|
||||
{t("search.input.deepThink")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
onClick={DeepThinkClick}
|
||||
>
|
||||
<VisibleKey shortcut={deepThinking} onKeyPress={DeepThinkClick}>
|
||||
<Brain
|
||||
className={`size-3 ${
|
||||
isDeepThinkActive
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-[#333] dark:text-white"
|
||||
}`}
|
||||
/>
|
||||
</VisibleKey>
|
||||
{isDeepThinkActive && (
|
||||
<span
|
||||
className={`${
|
||||
isDeepThinkActive ? "text-[#0072FF]" : "dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{t("search.input.deepThink")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<SearchPopover
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={setIsSearchActive}
|
||||
getDataSourcesByServer={getDataSourcesByServer}
|
||||
/>
|
||||
{source?.datasource?.visible && (
|
||||
<SearchPopover
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={setIsSearchActive}
|
||||
getDataSourcesByServer={getDataSourcesByServer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{source?.mcp_servers?.visible && (
|
||||
<MCPPopover
|
||||
isMCPActive={isMCPActive}
|
||||
setIsMCPActive={setIsMCPActive}
|
||||
getMCPByServer={getMCPByServer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!source?.datasource?.visible &&
|
||||
(source?.type !== "deep_think" || !source?.config?.visible) &&
|
||||
!source?.mcp_servers?.visible ? (
|
||||
<div className="px-[9px]">
|
||||
<Copyright />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -464,21 +512,18 @@ ChatInputProps) {
|
||||
></div>
|
||||
)}
|
||||
|
||||
{isChatPage ? null : (
|
||||
{isChatPage || hasModules?.length !== 2 ? null : (
|
||||
<div className="relative w-16 flex justify-end items-center">
|
||||
{showTooltip && modifierKeyPressed ? (
|
||||
<div
|
||||
className={`absolute left-1 z-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
|
||||
>
|
||||
{modeSwitch}
|
||||
{showTooltip && (
|
||||
<div className="absolute right-[52px] -top-2 z-10">
|
||||
<VisibleKey shortcut={modeSwitch} />
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<ChatSwitch
|
||||
isChatMode={isChatMode}
|
||||
onChange={(value: boolean) => {
|
||||
value && disabledChange();
|
||||
changeMode && changeMode(value);
|
||||
setSourceData(undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import RichIcon from "@/components/Common/Icons/RichIcon";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
|
||||
interface ListRightProps {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
showIndex: boolean;
|
||||
currentIndex: number;
|
||||
goToTwoPage?: (item: any) => void;
|
||||
goToTwoPage?: () => void;
|
||||
}
|
||||
|
||||
export default function ListRight({
|
||||
export interface RichCategoriesProps {
|
||||
item: any;
|
||||
isSelected: boolean;
|
||||
goToTwoPage?: () => void;
|
||||
}
|
||||
|
||||
export function RichCategories({
|
||||
item,
|
||||
isSelected,
|
||||
showIndex,
|
||||
currentIndex,
|
||||
goToTwoPage,
|
||||
}: ListRightProps) {
|
||||
}: RichCategoriesProps) {
|
||||
return (
|
||||
<div className="flex-1 text-right min-w-[160px] h-full pl-5 text-[12px] flex gap-2 items-center justify-end relative">
|
||||
<>
|
||||
{item?.rich_categories ? null : (
|
||||
<div
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
className={`w-4 h-4 cursor-pointer`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage && goToTwoPage(item);
|
||||
goToTwoPage && goToTwoPage();
|
||||
}}
|
||||
>
|
||||
<TypeIcon
|
||||
@@ -31,7 +38,7 @@ export default function ListRight({
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage && goToTwoPage(item);
|
||||
goToTwoPage && goToTwoPage();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -41,10 +48,10 @@ export default function ListRight({
|
||||
<div className="flex items-center justify-end max-w-[calc(100%-20px)] whitespace-nowrap">
|
||||
<RichIcon
|
||||
item={item}
|
||||
className="w-4 h-4 mr-2 cursor-pointer"
|
||||
className={`w-4 h-4 mr-2 cursor-pointer`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToTwoPage && goToTwoPage(item);
|
||||
goToTwoPage && goToTwoPage();
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
@@ -94,32 +101,42 @@ export default function ListRight({
|
||||
""}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
{isSelected ? (
|
||||
<div
|
||||
className={`absolute ${
|
||||
showIndex && currentIndex < 10 ? "right-7" : "right-0"
|
||||
} w-4 h-4 flex items-end justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md ${
|
||||
isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
↩︎
|
||||
</div>
|
||||
) : null}
|
||||
export default function ListRight({
|
||||
item,
|
||||
isSelected,
|
||||
showIndex,
|
||||
currentIndex,
|
||||
goToTwoPage,
|
||||
}: ListRightProps) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-1 text-right min-w-[160px] pl-5 justify-end w-full h-full text-[12px] gap-2 items-center relative`}
|
||||
>
|
||||
<RichCategories
|
||||
item={item}
|
||||
isSelected={false}
|
||||
goToTwoPage={goToTwoPage}
|
||||
/>
|
||||
|
||||
{showIndex && currentIndex < 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] rounded-md ${
|
||||
isSelected
|
||||
? "shadow-[-6px_0px_6px_2px_#950599]"
|
||||
: "shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]"
|
||||
}`}
|
||||
>
|
||||
{currentIndex}
|
||||
</div>
|
||||
) : null}
|
||||
{isSelected && (
|
||||
<VisibleKey
|
||||
shortcut="↩︎"
|
||||
rootClassName={clsx("!absolute", [
|
||||
showIndex && currentIndex < 10 ? "right-9" : "right-2",
|
||||
])}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showIndex && currentIndex < 10 && (
|
||||
<VisibleKey
|
||||
shortcut={String(currentIndex === 9 ? 0 : currentIndex + 1)}
|
||||
rootClassName="!absolute right-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
351
src/components/Search/MCPPopover.tsx
Normal file
351
src/components/Search/MCPPopover.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
Hammer,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
} from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDebounce } from "ahooks";
|
||||
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { DataSource } from "@/types/commands";
|
||||
import Checkbox from "@/components/Common/Checkbox";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import VisibleKey from "@/components/Common/VisibleKey";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import NoDataImage from "@/components/Common/NoDataImage";
|
||||
import PopoverInput from "../Common/PopoverInput";
|
||||
import FontIcon from "../Common/Icons/FontIcon";
|
||||
|
||||
interface SearchPopoverProps {
|
||||
isMCPActive: boolean;
|
||||
setIsMCPActive: () => void;
|
||||
getMCPByServer: (
|
||||
serverId: string,
|
||||
options?: {
|
||||
from?: number;
|
||||
size?: number;
|
||||
query?: string;
|
||||
}
|
||||
) => Promise<DataSource[]>;
|
||||
}
|
||||
|
||||
export default function SearchPopover({
|
||||
isMCPActive,
|
||||
setIsMCPActive,
|
||||
getMCPByServer,
|
||||
}: SearchPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const { connected } = useChatStore();
|
||||
|
||||
const [isRefreshDataSource, setIsRefreshDataSource] = useState(false);
|
||||
const [dataList, setDataList] = useState<DataSource[]>([]);
|
||||
|
||||
const MCPIds = useSearchStore((state) => state.MCPIds);
|
||||
const setMCPIds = useSearchStore((state) => state.setMCPIds);
|
||||
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 });
|
||||
|
||||
const getDataSourceList = useCallback(async () => {
|
||||
try {
|
||||
setPage(1);
|
||||
|
||||
const res: DataSource[] = await getMCPByServer(currentService?.id, {
|
||||
query: debouncedKeyword,
|
||||
});
|
||||
|
||||
console.log("getMCPByServer", res);
|
||||
|
||||
if (res?.length === 0) {
|
||||
setDataList([]);
|
||||
return;
|
||||
}
|
||||
const data = res?.length
|
||||
? [
|
||||
{
|
||||
id: "all",
|
||||
name: "search.input.searchPopover.allScope",
|
||||
},
|
||||
...res,
|
||||
]
|
||||
: [];
|
||||
|
||||
setDataList(data);
|
||||
} catch (err) {
|
||||
setDataList([]);
|
||||
console.error("datasource_search", err);
|
||||
}
|
||||
}, [currentService?.id, debouncedKeyword]);
|
||||
|
||||
const popoverButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const mcpSearch = useShortcutsStore((state) => state.mcpSearch);
|
||||
const mcpSearchScope = useShortcutsStore((state) => {
|
||||
return state.mcpSearchScope;
|
||||
});
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPage, setTotalPage] = useState(0);
|
||||
const [visibleList, setVisibleList] = useState<DataSource[]>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataList.length > 0) {
|
||||
setMCPIds(dataList.slice(1).map((item) => item.id));
|
||||
}
|
||||
}, [dataList]);
|
||||
|
||||
useEffect(() => {
|
||||
connected && getDataSourceList();
|
||||
}, [connected, currentService?.id, debouncedKeyword]);
|
||||
|
||||
useEffect(() => {
|
||||
setTotalPage(Math.max(Math.ceil(dataList.length / 10), 1));
|
||||
}, [dataList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dataList.length === 0) {
|
||||
return setVisibleList([]);
|
||||
}
|
||||
|
||||
const startIndex = (page - 1) * 9;
|
||||
const endIndex = startIndex + 9;
|
||||
|
||||
const list = [
|
||||
dataList[0],
|
||||
...dataList.slice(1).slice(startIndex, endIndex),
|
||||
];
|
||||
|
||||
setVisibleList(list);
|
||||
}, [dataList, page]);
|
||||
|
||||
const onSelectDataSource = useCallback(
|
||||
(id: string, checked: boolean, isAll: boolean) => {
|
||||
let nextSourceDataIds = new Set(MCPIds);
|
||||
|
||||
const ids = isAll ? visibleList.slice(1).map((item) => item.id) : [id];
|
||||
|
||||
for (const id of ids) {
|
||||
if (checked) {
|
||||
nextSourceDataIds.add(id);
|
||||
} else {
|
||||
nextSourceDataIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
setMCPIds(Array.from(nextSourceDataIds));
|
||||
},
|
||||
[visibleList, MCPIds]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshDataSource(true);
|
||||
|
||||
await getDataSourceList();
|
||||
|
||||
setTimeout(() => {
|
||||
setIsRefreshDataSource(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (page === 1) return;
|
||||
|
||||
setPage(page - 1);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (page === totalPage) return;
|
||||
|
||||
setPage(page + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-1 p-[3px] pr-1 rounded-md transition hover:bg-[#EDEDED] dark:hover:bg-[#202126] cursor-pointer",
|
||||
{
|
||||
"!bg-[rgba(0,114,255,0.3)]": isMCPActive,
|
||||
}
|
||||
)}
|
||||
onClick={setIsMCPActive}
|
||||
>
|
||||
<VisibleKey shortcut={mcpSearch} onKeyPress={setIsMCPActive}>
|
||||
<Hammer
|
||||
className={`size-3 ${
|
||||
isMCPActive
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-[#333] dark:text-white"
|
||||
}`}
|
||||
/>
|
||||
</VisibleKey>
|
||||
|
||||
{isMCPActive && (
|
||||
<>
|
||||
<span
|
||||
className={`${isMCPActive ? "text-[#0072FF]" : "dark:text-white"}`}
|
||||
>
|
||||
{t("search.input.MCP")}
|
||||
</span>
|
||||
|
||||
<Popover className="relative">
|
||||
<PopoverButton ref={popoverButtonRef} className="flex items-center">
|
||||
<VisibleKey
|
||||
shortcut={mcpSearchScope}
|
||||
onKeyPress={() => {
|
||||
popoverButtonRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={clsx("size-3", [
|
||||
isMCPActive
|
||||
? "text-[#0072FF] dark:text-[#0072FF]"
|
||||
: "text-[#333] dark:text-white",
|
||||
])}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</PopoverButton>
|
||||
|
||||
<PopoverPanel className="absolute z-50 left-0 bottom-6 w-[240px] overflow-y-auto bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
|
||||
<div
|
||||
className="text-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex justify-between">
|
||||
<span>{t("search.input.searchPopover.title")}</span>
|
||||
|
||||
<div
|
||||
onClick={handleRefresh}
|
||||
className="size-[24px] flex justify-center items-center rounded-lg border border-black/10 dark:border-white/10 cursor-pointer"
|
||||
>
|
||||
<VisibleKey shortcut="R" onKeyPress={handleRefresh}>
|
||||
<RefreshCw
|
||||
className={`size-3 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshDataSource ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</VisibleKey>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative h-8 my-2">
|
||||
<div className="absolute inset-0 flex items-center px-2 pointer-events-none">
|
||||
<VisibleKey
|
||||
shortcut="F"
|
||||
shortcutClassName="translate-x-0"
|
||||
onKeyPress={() => {
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PopoverInput
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visibleList.length > 0 ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{visibleList?.map((item, index) => {
|
||||
const { id, name } = item;
|
||||
|
||||
const isAll = index === 0;
|
||||
|
||||
const isChecked = () => {
|
||||
if (isAll) {
|
||||
return visibleList.slice(1).every((item) => {
|
||||
return MCPIds.includes(item.id);
|
||||
});
|
||||
} else {
|
||||
return MCPIds.includes(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-hidden">
|
||||
{isAll ? (
|
||||
<Layers className="size-[16px] text-[#0287FF]" />
|
||||
) : item.icon?.startsWith("font_") ? (
|
||||
<FontIcon name={item.icon} className="size-4" />
|
||||
) : (
|
||||
<TypeIcon item={item} className="size-4" />
|
||||
)}
|
||||
|
||||
<span className="truncate">
|
||||
{isAll && name ? t(name) : name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<VisibleKey
|
||||
shortcut={index === 9 ? "0" : String(index + 1)}
|
||||
shortcutClassName="-translate-x-3"
|
||||
onKeyPress={() => {
|
||||
onSelectDataSource(id, !isChecked(), isAll);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex justify-center items-center size-[24px]">
|
||||
<Checkbox
|
||||
checked={isChecked()}
|
||||
indeterminate={isAll}
|
||||
onChange={(value) =>
|
||||
onSelectDataSource(id, value, isAll)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<NoDataImage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleList.length > 0 && (
|
||||
<div className="flex items-center justify-between h-8 px-3 border-t dark:border-t-[#202126]">
|
||||
<VisibleKey shortcut="leftarrow" onKeyPress={handlePrev}>
|
||||
<ChevronLeft className="size-4" onClick={handlePrev} />
|
||||
</VisibleKey>
|
||||
|
||||
<div className="text-xs">
|
||||
{page}/{totalPage}
|
||||
</div>
|
||||
|
||||
<VisibleKey shortcut="rightarrow" onKeyPress={handleNext}>
|
||||
<ChevronRight className="size-4" onClick={handleNext} />
|
||||
</VisibleKey>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Command } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { isMac } from "@/utils/platform";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
|
||||
export const NoResults = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center"
|
||||
>
|
||||
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
|
||||
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
|
||||
{t("search.main.noResults")}
|
||||
</div>
|
||||
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
|
||||
{t("search.main.askCoco")}
|
||||
{isMac ? (
|
||||
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
<Command className="w-3 h-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-3 w-8 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
<span className="h-3 leading-3 inline-flex items-center text-xs">
|
||||
Ctrl
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
T
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user