mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-18 12:37:45 +01:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
569a61841c | ||
|
|
8b2fc07519 | ||
|
|
bf145c8697 | ||
|
|
0c3606820c | ||
|
|
3df86fc1c4 | ||
|
|
d01cbe1541 | ||
|
|
89a763dff7 | ||
|
|
0c42a51cb5 | ||
|
|
f514e5a5c9 | ||
|
|
b3aff2b353 | ||
|
|
bcb92bfd49 | ||
|
|
d9dea0ea38 | ||
|
|
d2eed4a1c4 | ||
|
|
c7e547b5fa | ||
|
|
eadd0988ba | ||
|
|
78bc83f38a | ||
|
|
84d9c6cdf0 | ||
|
|
0769545a92 | ||
|
|
118eaa55e3 | ||
|
|
ef1304ce5e | ||
|
|
51d3a9d090 | ||
|
|
7d0eced55a | ||
|
|
e81c5bbb6e | ||
|
|
bfc7b488ad | ||
|
|
249cc2eae4 | ||
|
|
388dac6452 | ||
|
|
dc8d1b5054 | ||
|
|
046c3dda82 | ||
|
|
60ce678e3e | ||
|
|
8d79b9ba1a | ||
|
|
969126ed89 | ||
|
|
e2df2b583a | ||
|
|
9d948d4fc6 | ||
|
|
81c770ba7e | ||
|
|
c9e9a72a0e | ||
|
|
96e6aae30b | ||
|
|
d319f5ebc7 | ||
|
|
04ff358dc7 | ||
|
|
22872ab02f | ||
|
|
fcfd21be45 | ||
|
|
0044e9a536 | ||
|
|
44a3ea3868 | ||
|
|
b444dc35ae | ||
|
|
8c9ccef218 | ||
|
|
a3bc997efe | ||
|
|
910841013f |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
run: rustup toolchain install stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
out
|
||||
src/components/web
|
||||
|
||||
# Editor directories and files
|
||||
# .vscode/*
|
||||
|
||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -4,34 +4,46 @@
|
||||
"autolaunch",
|
||||
"Avenir",
|
||||
"callout",
|
||||
"changelogithub",
|
||||
"clsx",
|
||||
"codegen",
|
||||
"dataurl",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"fullscreen",
|
||||
"headlessui",
|
||||
"Icdbb",
|
||||
"icns",
|
||||
"INFINI",
|
||||
"infinilabs",
|
||||
"inputbox",
|
||||
"katex",
|
||||
"khtml",
|
||||
"languagedetector",
|
||||
"libappindicator",
|
||||
"librsvg",
|
||||
"libwebkit",
|
||||
"localstorage",
|
||||
"lucide",
|
||||
"maximizable",
|
||||
"Minimizable",
|
||||
"msvc",
|
||||
"nord",
|
||||
"nowrap",
|
||||
"nspanel",
|
||||
"nsstring",
|
||||
"overscan",
|
||||
"partialize",
|
||||
"patchelf",
|
||||
"Raycast",
|
||||
"rehype",
|
||||
"reqwest",
|
||||
"rgba",
|
||||
"rustup",
|
||||
"screenshotable",
|
||||
"serde",
|
||||
"swatinem",
|
||||
"tailwindcss",
|
||||
"tauri",
|
||||
"thiserror",
|
||||
@@ -46,6 +58,7 @@
|
||||
"VITE",
|
||||
"walkdir",
|
||||
"webviews",
|
||||
"xzvf",
|
||||
"yuque",
|
||||
"zustand"
|
||||
],
|
||||
|
||||
@@ -7,6 +7,12 @@ theme: book
|
||||
disablePathToLower: true
|
||||
enableGitInfo: false
|
||||
|
||||
outputs:
|
||||
home:
|
||||
- HTML
|
||||
- RSS
|
||||
- JSON
|
||||
|
||||
# Needed for mermaid/katex shortcodes
|
||||
markup:
|
||||
goldmark:
|
||||
|
||||
@@ -9,14 +9,58 @@ Information about release notes of Coco Server is provided here.
|
||||
|
||||
## Latest (In development)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Features
|
||||
|
||||
### Bug fix
|
||||
|
||||
### Improvements
|
||||
|
||||
## 0.3.0 (2025-03-31)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- feat: add web pages components #277
|
||||
- feat: support for customizing some of the preset shortcuts #316
|
||||
|
||||
### Features
|
||||
|
||||
- feat: support multi websocket connections #314
|
||||
- feat: add support for embeddable web widget #277
|
||||
|
||||
### Bug fix
|
||||
|
||||
### Improvements
|
||||
|
||||
- refactor: refactor invoke related code #309
|
||||
- refactor: hide apps without icon #312
|
||||
|
||||
## 0.2.1 (2025-03-14)
|
||||
|
||||
### Features
|
||||
|
||||
- support for automatic in-app updates #274
|
||||
|
||||
### Breaking changes
|
||||
|
||||
### Bug fix
|
||||
|
||||
- Fix the issue that the fusion search include disabled servers
|
||||
- Fix incorrect version type: should be string instead of u32
|
||||
- Fix the chat end judgment type #280
|
||||
- Fix the chat scrolling and chat rendering #282
|
||||
- Fix: store data is not shared among multiple windows #298
|
||||
|
||||
### Improvements
|
||||
|
||||
- Refactor: chat components #273
|
||||
- Feat:add endpoint display #282
|
||||
- Chore: chat window min width & remove input bg #284
|
||||
- Chore: remove selected function & add hide_coco #286
|
||||
- Chore:websocket timeout increased to 2 minutes #289
|
||||
- Chore: remove chat input border & clear input #295
|
||||
|
||||
## 0.2.0 (2025-03-07)
|
||||
|
||||
### Features
|
||||
@@ -54,7 +98,6 @@ Information about release notes of Coco Server is provided here.
|
||||
- Allow to switch servers in the settings page
|
||||
- etc.
|
||||
|
||||
|
||||
## 0.1.0 (2025-02-16)
|
||||
|
||||
### Features
|
||||
|
||||
33
package.json
33
package.json
@@ -1,11 +1,16 @@
|
||||
{
|
||||
"name": "coco",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build:web": "tsc && tsup",
|
||||
"publish:web": "cd dist/search-chat && npm publish",
|
||||
"publish:web:beta": "cd dist/search-chat && npm publish --tag beta",
|
||||
"publish:web:alpha": "cd dist/search-chat && npm publish --tag alpha",
|
||||
"publish:web:rc": "cd dist/search-chat && npm publish --tag rc",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"release": "release-it",
|
||||
@@ -13,19 +18,21 @@
|
||||
"release-beta": "release-it --preRelease=beta --preReleaseBase=1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"@tauri-apps/api": "^2.3.0",
|
||||
"@tauri-apps/api": "^2.4.0",
|
||||
"@tauri-apps/plugin-autostart": "~2.2.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.2.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.0",
|
||||
"@tauri-apps/plugin-global-shortcut": "~2.0.0",
|
||||
"@tauri-apps/plugin-http": "~2.0.2",
|
||||
"@tauri-apps/plugin-os": "^2.2.0",
|
||||
"@tauri-apps/plugin-os": "^2.2.1",
|
||||
"@tauri-apps/plugin-process": "^2.2.0",
|
||||
"@tauri-apps/plugin-shell": "^2.2.0",
|
||||
"@tauri-apps/plugin-updater": "^2.5.1",
|
||||
"@tauri-apps/plugin-updater": "^2.6.1",
|
||||
"@tauri-apps/plugin-websocket": "~2.3.0",
|
||||
"@tauri-apps/plugin-window": "2.0.0-alpha.1",
|
||||
"@wavesurfer/react": "^1.0.9",
|
||||
"ahooks": "^3.8.4",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -34,8 +41,8 @@
|
||||
"i18next-browser-languagedetector": "^8.0.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.461.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"nanoid": "^5.1.2",
|
||||
"mermaid": "^11.5.0",
|
||||
"nanoid": "^5.1.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hotkeys-hook": "^4.6.1",
|
||||
@@ -49,28 +56,30 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0",
|
||||
"tauri-plugin-fs-pro-api": "^2.3.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.1.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.2.0",
|
||||
"tauri-plugin-screenshots-api": "^2.1.0",
|
||||
"use-debounce": "^10.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"wavesurfer.js": "^7.9.3",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.3.1",
|
||||
"@tauri-apps/cli": "^2.4.0",
|
||||
"@types/dom-speech-recognition": "^0.0.4",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^22.13.9",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/node": "^22.13.11",
|
||||
"@types/react": "^18.3.19",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react-i18next": "^8.1.0",
|
||||
"@types/react-katex": "^3.0.4",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"immer": "^10.1.1",
|
||||
"postcss": "^8.5.3",
|
||||
"release-it": "^18.1.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^5.4.14"
|
||||
|
||||
1459
pnpm-lock.yaml
generated
1459
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
752
src-tauri/Cargo.lock
generated
752
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "coco"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Search, connect, collaborate – all in one place."
|
||||
authors = ["INFINI Labs"]
|
||||
edition = "2021"
|
||||
@@ -47,7 +47,7 @@ tokio-native-tls = "0.3" # For wss connections
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] }
|
||||
hyper = { version = "0.14", features = ["client"] }
|
||||
reqwest = "0.12.12"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
futures = "0.3.31"
|
||||
ordered-float = { version = "4.6.0", default-features = false }
|
||||
lazy_static = "1.5.0"
|
||||
@@ -68,6 +68,7 @@ url = "2.5.2"
|
||||
http = "1.1.0"
|
||||
tungstenite = "0.24.0"
|
||||
env_logger = "0.11.5"
|
||||
tokio-util = "0.7.14"
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
@@ -89,3 +90,4 @@ strip = true # Ensures debug symbols are removed.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-autostart = "^2.2"
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
|
||||
@@ -31,5 +31,12 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Coco AI needs access to your microphone for voice input and audio recording features.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Coco AI requires camera access for scanning documents and capturing images.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Coco AI uses speech recognition to convert your voice into text for a hands-free experience.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB |
@@ -67,6 +67,7 @@
|
||||
"macos-permissions:default",
|
||||
"screenshots:default",
|
||||
"core:window:allow-set-theme",
|
||||
"process:default"
|
||||
"process:default",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled",
|
||||
"global-shortcut:default"
|
||||
"global-shortcut:default",
|
||||
"updater:default"
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn chat_history<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
from: u32,
|
||||
size: u32,
|
||||
@@ -44,7 +44,7 @@ async fn handle_raw_response(response: Response) -> Result<Result<String, String
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn session_chat_history<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
from: u32,
|
||||
@@ -69,11 +69,11 @@ pub async fn session_chat_history<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_open", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
@@ -85,11 +85,11 @@ pub async fn open_session_chat<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_close", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
@@ -100,11 +100,11 @@ pub async fn close_session_chat<R: Runtime>(
|
||||
}
|
||||
#[tauri::command]
|
||||
pub async fn cancel_session_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<String, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
let query_params = HashMap::new();
|
||||
let path = format!("/chat/{}/_cancel", session_id);
|
||||
|
||||
let response = HttpClient::post(&server_id, path.as_str(), Some(query_params), None)
|
||||
@@ -116,8 +116,9 @@ pub async fn cancel_session_chat<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn new_chat<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
) -> Result<GetResponse, String> {
|
||||
@@ -131,7 +132,10 @@ pub async fn new_chat<R: Runtime>(
|
||||
None
|
||||
};
|
||||
|
||||
let response = HttpClient::post(&server_id, "/chat/_new", query_params, body)
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let response = HttpClient::advanced_post(&server_id, "/chat/_new", Some(headers), query_params, body)
|
||||
.await
|
||||
.map_err(|e| format!("Error sending message: {}", e))?;
|
||||
|
||||
@@ -154,8 +158,9 @@ pub async fn new_chat<R: Runtime>(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
_app_handle: AppHandle<R>,
|
||||
server_id: String,
|
||||
websocket_id: String,
|
||||
session_id: String,
|
||||
message: String,
|
||||
query_params: Option<HashMap<String, Value>>, //search,deep_thinking
|
||||
@@ -165,9 +170,12 @@ pub async fn send_message<R: Runtime>(
|
||||
message: Some(message),
|
||||
};
|
||||
|
||||
let mut headers = HashMap::new();
|
||||
headers.insert("WEBSOCKET-SESSION-ID".to_string(), websocket_id.into());
|
||||
|
||||
let body = reqwest::Body::from(serde_json::to_string(&msg).unwrap());
|
||||
let response =
|
||||
HttpClient::advanced_post(&server_id, path.as_str(), None, query_params, Some(body))
|
||||
HttpClient::advanced_post(&server_id, path.as_str(), Some(headers), query_params, Some(body))
|
||||
.await
|
||||
.map_err(|e| format!("Error cancel session: {}", e))?;
|
||||
|
||||
|
||||
@@ -60,7 +60,10 @@ fn current_autostart<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<bool, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn change_autostart<R: Runtime>(app: tauri::AppHandle<R>, open: bool) -> Result<(), String> {
|
||||
pub async fn change_autostart<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
open: bool,
|
||||
) -> Result<(), String> {
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ pub struct ChatRequestMessage {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct NewChatResponse {
|
||||
pub _id: String,
|
||||
pub _source: Source,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct DataSourceReference {
|
||||
pub r#type: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub id: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -16,6 +16,7 @@ impl SearchSourceRegistry {
|
||||
sources.insert(source_id, Arc::new(source));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn clear(&self) {
|
||||
let mut sources = self.sources.write().await;
|
||||
sources.clear();
|
||||
@@ -26,6 +27,7 @@ impl SearchSourceRegistry {
|
||||
sources.remove(id);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_source(&self, id: &str) -> Option<Arc<dyn SearchSource>> {
|
||||
let sources = self.sources.read().await;
|
||||
sources.get(id).cloned()
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::error::Error;
|
||||
pub struct SearchResponse<T> {
|
||||
pub took: u64,
|
||||
pub timed_out: bool,
|
||||
pub _shards: Shards,
|
||||
pub _shards: Option<Shards>,
|
||||
pub hits: Hits<T>,
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ where
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn parse_search_results_with_score<T>(
|
||||
response: Response,
|
||||
) -> Result<Vec<(T, Option<f64>)>, Box<dyn Error>>
|
||||
|
||||
@@ -29,6 +29,11 @@ pub struct AuthProvider {
|
||||
pub sso: Sso,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MinimalClientVersion {
|
||||
number: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
@@ -39,6 +44,7 @@ pub struct Server {
|
||||
pub endpoint: String,
|
||||
pub provider: Provider,
|
||||
pub version: Version,
|
||||
pub minimal_client_version: Option<MinimalClientVersion>,
|
||||
pub updated: String,
|
||||
#[serde(default = "default_enabled_type")]
|
||||
pub enabled: bool,
|
||||
@@ -70,7 +76,6 @@ impl Hash for Server {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerAccessToken {
|
||||
#[serde(default = "default_empty_string")] // Custom default function for empty string
|
||||
|
||||
@@ -25,9 +25,11 @@ pub enum SearchError {
|
||||
Timeout,
|
||||
|
||||
#[error("Unknown error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
Unknown(String),
|
||||
|
||||
#[error("InternalError error: {0}")]
|
||||
#[allow(dead_code)]
|
||||
InternalError(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -11,18 +11,14 @@ mod util;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
// use crate::common::traits::SearchSource;
|
||||
use crate::common::{MAIN_WINDOW_LABEL, SETTINGS_WINDOW_LABEL};
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::server::servers::{load_or_insert_default_server, load_servers_token};
|
||||
use autostart::{change_autostart, enable_autostart};
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tauri::ActivationPolicy;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, State, WebviewWindow, Window,
|
||||
WindowEvent,
|
||||
AppHandle, Emitter, Manager, PhysicalPosition, Runtime, WebviewWindow, Window, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tokio::runtime::Runtime as RT;
|
||||
@@ -35,7 +31,7 @@ lazy_static! {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn change_window_height(handle: AppHandle, height: u32) {
|
||||
async fn change_window_height(handle: AppHandle, height: u32) {
|
||||
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
|
||||
let mut size = window.outer_size().unwrap();
|
||||
@@ -45,10 +41,12 @@ fn change_window_height(handle: AppHandle, height: u32) {
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ThemeChangedPayload {
|
||||
#[allow(dead_code)]
|
||||
is_dark_mode: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
struct Payload {
|
||||
args: Vec<String>,
|
||||
cwd: String,
|
||||
@@ -56,7 +54,7 @@ struct Payload {
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut ctx = tauri::generate_context!();
|
||||
let ctx = tauri::generate_context!();
|
||||
// Initialize logger
|
||||
env_logger::init();
|
||||
|
||||
@@ -83,7 +81,8 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs_pro::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_screenshots::init())
|
||||
.plugin(tauri_plugin_process::init());
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
|
||||
// Conditional compilation for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -125,7 +124,11 @@ pub fn run() {
|
||||
// server::get_coco_server_connectors,
|
||||
server::websocket::connect_to_server,
|
||||
server::websocket::disconnect,
|
||||
get_app_search_source
|
||||
get_app_search_source,
|
||||
server::attachment::upload_attachment,
|
||||
server::attachment::get_attachment,
|
||||
server::attachment::delete_attachment,
|
||||
server::transcription::transcription
|
||||
])
|
||||
.setup(|app| {
|
||||
let registry = SearchSourceRegistry::default();
|
||||
@@ -134,7 +137,7 @@ pub fn run() {
|
||||
app.manage(server::websocket::WebSocketManager::default());
|
||||
|
||||
// Get app handle
|
||||
let app_handle = app.handle().clone();
|
||||
// let app_handle = app.handle().clone();
|
||||
|
||||
// Create a single Tokio runtime instance
|
||||
let rt = RT::new().expect("Failed to create Tokio runtime");
|
||||
@@ -146,7 +149,7 @@ pub fn run() {
|
||||
});
|
||||
|
||||
shortcut::enable_shortcut(&app);
|
||||
// enable_tray(app);
|
||||
|
||||
enable_autostart(app);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -223,11 +226,11 @@ pub async fn init<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
let coco_servers = server::servers::get_all_servers();
|
||||
|
||||
// Get the registry from Tauri's state
|
||||
let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
||||
// let registry: State<SearchSourceRegistry> = app_handle.state::<SearchSourceRegistry>();
|
||||
|
||||
for server in coco_servers {
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,37 +246,28 @@ async fn init_app_search_source<R: Runtime>(app_handle: &AppHandle<R>) -> Result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn show_coco(app_handle: AppHandle) {
|
||||
handle_open_coco(&app_handle);
|
||||
async fn show_coco<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
if let Some(window) = app_handle.get_window(MAIN_WINDOW_LABEL) {
|
||||
let _ = app_handle.emit("show-coco", ());
|
||||
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn hide_coco(app: tauri::AppHandle) {
|
||||
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
match window.is_visible() {
|
||||
Ok(true) => {
|
||||
if let Err(err) = window.hide() {
|
||||
eprintln!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
println!("Window successfully hidden.");
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
println!("Window is already hidden.");
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Failed to check window visibility: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_open_coco(app: &AppHandle) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
move_window_to_active_monitor(&window);
|
||||
|
||||
window.show().unwrap();
|
||||
window.set_visible_on_all_workspaces(true).unwrap();
|
||||
window.set_always_on_top(true).unwrap();
|
||||
window.set_focus().unwrap();
|
||||
} else {
|
||||
eprintln!("Main window not found.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,88 +364,15 @@ fn move_window_to_active_monitor<R: Runtime>(window: &Window<R>) {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_hide_coco(app: &AppHandle) {
|
||||
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
|
||||
if let Err(err) = window.hide() {
|
||||
eprintln!("Failed to hide the window: {}", err);
|
||||
} else {
|
||||
println!("Window successfully hidden.");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Main window not found.");
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_tray(app: &mut tauri::App) {
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
};
|
||||
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit Coco", true, None::<&str>).unwrap();
|
||||
let settings_i = MenuItem::with_id(app, "settings", "Settings...", true, None::<&str>).unwrap();
|
||||
let open_i = MenuItem::with_id(app, "open", "Show Coco", true, None::<&str>).unwrap();
|
||||
// let about_i = MenuItem::with_id(app, "about", "About Coco", true, None::<&str>).unwrap();
|
||||
// let hide_i = MenuItem::with_id(app, "hide", "Hide Coco", true, None::<&str>).unwrap();
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&open_i)
|
||||
.separator()
|
||||
// .item(&hide_i)
|
||||
// .item(&about_i)
|
||||
.item(&settings_i)
|
||||
.separator()
|
||||
.item(&quit_i)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("tray")
|
||||
.icon_as_template(true)
|
||||
// .icon(app.default_window_icon().unwrap().clone())
|
||||
.icon(
|
||||
Image::from_bytes(include_bytes!("../assets/tray-mac.ico"))
|
||||
.expect("Failed to load icon"),
|
||||
)
|
||||
.menu(&menu)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => {
|
||||
handle_open_coco(app);
|
||||
}
|
||||
"hide" => {
|
||||
handle_hide_coco(app);
|
||||
}
|
||||
"about" => {
|
||||
let _ = app.emit("open_settings", "about");
|
||||
}
|
||||
"settings" => {
|
||||
// windows failed to open second window, issue: https://github.com/tauri-apps/tauri/issues/11144 https://github.com/tauri-apps/tauri/issues/8196
|
||||
//#[cfg(windows)]
|
||||
let _ = app.emit("open_settings", "settings");
|
||||
|
||||
// #[cfg(not(windows))]
|
||||
// open_settings(&app);
|
||||
}
|
||||
"quit" => {
|
||||
println!("quit menu item was clicked");
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {
|
||||
println!("menu item {:?} not handled", event.id);
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn open_settings(app: &tauri::AppHandle) {
|
||||
use tauri::webview::WebviewBuilder;
|
||||
println!("settings menu item was clicked");
|
||||
let window = app.get_webview_window("settings");
|
||||
if let Some(window) = window {
|
||||
window.show().unwrap();
|
||||
window.set_focus().unwrap();
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
let window = tauri::window::WindowBuilder::new(app, "settings")
|
||||
.title("Settings Window")
|
||||
|
||||
@@ -31,6 +31,10 @@ impl ApplicationSearchSource {
|
||||
let apps = ctx.get_all_apps();
|
||||
|
||||
for app in &apps {
|
||||
if app.icon_path.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = if cfg!(target_os = "macos") {
|
||||
app.app_desktop_path.clone()
|
||||
} else {
|
||||
@@ -120,6 +124,7 @@ impl SearchSource for ApplicationSearchSource {
|
||||
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(),
|
||||
|
||||
151
src-tauri/src/server/attachment.rs
Normal file
151
src-tauri/src/server/attachment.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
use super::servers::{get_server_by_id, get_server_token};
|
||||
use crate::server::http_client::HttpClient;
|
||||
use reqwest::multipart::{Form, Part};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
use tauri::command;
|
||||
use tokio::fs::File;
|
||||
use tokio_util::codec::{BytesCodec, FramedRead};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UploadAttachmentResponse {
|
||||
pub acknowledged: bool,
|
||||
pub attachments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentSource {
|
||||
pub id: String,
|
||||
pub created: String,
|
||||
pub updated: String,
|
||||
pub session: String,
|
||||
pub name: String,
|
||||
pub icon: String,
|
||||
pub url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHit {
|
||||
pub _index: String,
|
||||
pub _type: String,
|
||||
pub _id: String,
|
||||
pub _score: f64,
|
||||
pub _source: AttachmentSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentHits {
|
||||
pub total: Value,
|
||||
pub max_score: f64,
|
||||
pub hits: Vec<AttachmentHit>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct GetAttachmentResponse {
|
||||
pub took: u32,
|
||||
pub timed_out: bool,
|
||||
pub _shards: Value,
|
||||
pub hits: AttachmentHits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteAttachmentResponse {
|
||||
pub _id: String,
|
||||
pub result: String,
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn upload_attachment(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
file_paths: Vec<PathBuf>,
|
||||
) -> Result<UploadAttachmentResponse, String> {
|
||||
let mut form = Form::new();
|
||||
|
||||
for file_path in file_paths {
|
||||
let file = File::open(&file_path)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let stream = FramedRead::new(file, BytesCodec::new());
|
||||
let file_name = file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("Invalid filename")?;
|
||||
|
||||
let part =
|
||||
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
|
||||
|
||||
form = form.part("files", part);
|
||||
}
|
||||
|
||||
let server = get_server_by_id(&server_id).ok_or("Server not found")?;
|
||||
let url = HttpClient::join_url(&server.endpoint, &format!("chat/{}/_upload", session_id));
|
||||
|
||||
let token = get_server_token(&server_id).await?;
|
||||
let mut headers = HashMap::new();
|
||||
if let Some(token) = token {
|
||||
headers.insert("X-API-TOKEN".to_string(), token.access_token);
|
||||
}
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(url)
|
||||
.multipart(form)
|
||||
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result = response
|
||||
.json::<UploadAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(result)
|
||||
} else {
|
||||
Err(format!("Upload failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn get_attachment(
|
||||
server_id: String,
|
||||
session_id: String,
|
||||
) -> Result<GetAttachmentResponse, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("session".to_string(), serde_json::Value::String(session_id));
|
||||
|
||||
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params)).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<GetAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!("Request failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
|
||||
let response =
|
||||
HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None).await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<DeleteAttachmentResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.result
|
||||
.eq("deleted")
|
||||
.then_some(true)
|
||||
.ok_or("Delete operation was not successful".to_string())
|
||||
} else {
|
||||
Err(format!("Delete failed with status: {}", response.status()))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
use crate::common::auth::RequestAccessTokenResponse;
|
||||
use crate::common::register::SearchSourceRegistry;
|
||||
use crate::common::server::ServerAccessToken;
|
||||
use crate::server::http_client::HttpClient;
|
||||
use crate::server::profile::get_user_profiles;
|
||||
use crate::server::search::CocoSearchSource;
|
||||
use crate::server::servers::{get_server_by_id, persist_servers, persist_servers_token, save_access_token, save_server};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use 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 tauri::{AppHandle, Manager, Runtime};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
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)
|
||||
@@ -54,9 +52,8 @@ pub async fn handle_sso_callback<R: Runtime>(
|
||||
save_access_token(server_id.clone(), access_token);
|
||||
persist_servers_token(&app_handle)?;
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
// Update the server's profile using the util::http::HttpClient::get method
|
||||
let profile = get_user_profiles(app_handle.clone(), server_id.clone()).await;
|
||||
|
||||
@@ -65,6 +65,7 @@ pub async fn refresh_all_connectors<R: Runtime>(app_handle: &AppHandle<R>) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_connectors_from_cache_or_remote(
|
||||
server_id: &str,
|
||||
) -> Result<Vec<Connector>, String> {
|
||||
@@ -96,7 +97,7 @@ pub async fn get_connectors_from_cache_or_remote(
|
||||
|
||||
pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, String> {
|
||||
// Use the generic GET method from HttpClient
|
||||
let resp = HttpClient::get(&id, "/connector/_search",None)
|
||||
let resp = HttpClient::get(&id, "/connector/_search", None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// dbg!("Error fetching connector for id {}: {}", &id, &e);
|
||||
@@ -104,9 +105,9 @@ pub async fn fetch_connectors_by_server(id: &str) -> Result<Vec<Connector>, Stri
|
||||
})?;
|
||||
|
||||
// Parse the search results directly from the response body
|
||||
let datasource: Vec<Connector> = parse_search_results(resp).await.map_err(|e| {
|
||||
e.to_string()
|
||||
})?;
|
||||
let datasource: Vec<Connector> = parse_search_results(resp)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Save the connectors to the cache
|
||||
save_connectors_to_cache(&id, datasource.clone());
|
||||
|
||||
@@ -22,6 +22,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
|
||||
cache.insert(server_id.to_string(), datasources_map);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, DataSource>> {
|
||||
let cache = DATASOURCE_CACHE.read().unwrap(); // Acquire read lock
|
||||
// dbg!("cache: {:?}", &cache);
|
||||
@@ -29,7 +30,7 @@ pub fn get_datasources_from_cache(server_id: &str) -> Option<HashMap<String, Dat
|
||||
Some(server_cache.clone())
|
||||
}
|
||||
|
||||
pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
pub async fn refresh_all_datasources<R: Runtime>(_app_handle: &AppHandle<R>) -> Result<(), String> {
|
||||
// dbg!("Attempting to refresh all datasources");
|
||||
|
||||
let servers = get_all_servers();
|
||||
@@ -40,8 +41,7 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
||||
// dbg!("fetch datasources for server: {}", &server.id);
|
||||
|
||||
// Attempt to get datasources by server, and continue even if it fails
|
||||
let connectors =
|
||||
match get_datasources_by_server(server.id.as_str()).await {
|
||||
let connectors = match get_datasources_by_server(server.id.as_str()).await {
|
||||
Ok(connectors) => {
|
||||
// Process connectors only after fetching them
|
||||
let connectors_map: HashMap<String, DataSource> = connectors
|
||||
@@ -79,21 +79,15 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
|
||||
cache.extend(server_map);
|
||||
cache.len()
|
||||
};
|
||||
// dbg!("datasource_map size: {:?}", cache_size);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_datasources_by_server(
|
||||
id: &str,
|
||||
) -> Result<Vec<DataSource>, String> {
|
||||
|
||||
pub async fn get_datasources_by_server(id: &str) -> Result<Vec<DataSource>, String> {
|
||||
// Perform the async HTTP request outside the cache lock
|
||||
let resp = HttpClient::get(id, "/datasource/_search", None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// dbg!("Error fetching datasource: {}", &e);
|
||||
format!("Error fetching datasource: {}", e)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use reqwest::{Client, Method, RequestBuilder};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use tauri::ipc::RuntimeCapability;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub static HTTP_CLIENT: Lazy<Mutex<Client>> = Lazy::new(|| {
|
||||
@@ -36,9 +35,12 @@ impl HttpClient {
|
||||
headers: Option<HashMap<String, String>>,
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
let mut request_builder = Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
let request_builder =
|
||||
Self::get_request_builder(method, url, headers, query_params, body).await;
|
||||
|
||||
let response = request_builder.send().await
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send request: {}", e))?;
|
||||
Ok(response)
|
||||
}
|
||||
@@ -55,7 +57,6 @@ impl HttpClient {
|
||||
// Build the request
|
||||
let mut request_builder = client.request(method.clone(), url);
|
||||
|
||||
|
||||
if let Some(h) = headers {
|
||||
let mut req_headers = reqwest::header::HeaderMap::new();
|
||||
for (key, value) in h.into_iter() {
|
||||
@@ -93,23 +94,21 @@ impl HttpClient {
|
||||
let url = HttpClient::join_url(&s.endpoint, path);
|
||||
|
||||
// Retrieve the token for the server (token is optional)
|
||||
let token = get_server_token(server_id).map(|t| t.access_token.clone());
|
||||
let token = get_server_token(server_id)
|
||||
.await?
|
||||
.map(|t| t.access_token.clone());
|
||||
|
||||
let mut headers = if let Some(custom_headers) = custom_headers {
|
||||
custom_headers
|
||||
} else {
|
||||
let mut headers = HashMap::new();
|
||||
let headers = HashMap::new();
|
||||
headers
|
||||
};
|
||||
|
||||
if let Some(t) = token {
|
||||
headers.insert(
|
||||
"X-API-TOKEN".to_string(),
|
||||
t,
|
||||
);
|
||||
headers.insert("X-API-TOKEN".to_string(), t);
|
||||
}
|
||||
|
||||
|
||||
// dbg!(&server_id);
|
||||
// dbg!(&url);
|
||||
// dbg!(&headers);
|
||||
@@ -121,7 +120,10 @@ impl HttpClient {
|
||||
}
|
||||
|
||||
// Convenience method for GET requests (as it's the most common)
|
||||
pub async fn get(server_id: &str, path: &str, query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
pub async fn get(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::GET, path, None, query_params, None).await
|
||||
}
|
||||
@@ -143,10 +145,19 @@ impl HttpClient {
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::POST, path, custom_headers, query_params, body).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::POST,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for PUT requests
|
||||
#[allow(dead_code)]
|
||||
pub async fn put(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
@@ -154,13 +165,33 @@ impl HttpClient {
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
body: Option<reqwest::Body>,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::PUT, path, custom_headers, query_params, body).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::PUT,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
body,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// Convenience method for DELETE requests
|
||||
pub async fn delete(server_id: &str, path: &str, custom_headers: Option<HashMap<String, String>>,
|
||||
#[allow(dead_code)]
|
||||
pub async fn delete(
|
||||
server_id: &str,
|
||||
path: &str,
|
||||
custom_headers: Option<HashMap<String, String>>,
|
||||
query_params: Option<HashMap<String, Value>>, // Add query parameters
|
||||
) -> Result<reqwest::Response, String> {
|
||||
HttpClient::send_request(server_id, Method::DELETE, path, custom_headers, query_params, None).await
|
||||
HttpClient::send_request(
|
||||
server_id,
|
||||
Method::DELETE,
|
||||
path,
|
||||
custom_headers,
|
||||
query_params,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//! This file contains Rust APIs related to Coco Server management.
|
||||
|
||||
pub mod attachment;
|
||||
pub mod auth;
|
||||
pub mod servers;
|
||||
pub mod connector;
|
||||
pub mod datasource;
|
||||
pub mod http_client;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
pub mod servers;
|
||||
pub mod transcription;
|
||||
pub mod websocket;
|
||||
|
||||
@@ -12,6 +12,8 @@ use ordered_float::OrderedFloat;
|
||||
use reqwest::{Client, Method, RequestBuilder};
|
||||
use std::collections::HashMap;
|
||||
// use std::hash::Hash;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) struct DocumentsSizedCollector {
|
||||
size: u64,
|
||||
/// Documents and scores
|
||||
@@ -20,6 +22,7 @@ pub(crate) struct DocumentsSizedCollector {
|
||||
docs: Vec<(String, Document, OrderedFloat<f64>)>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl DocumentsSizedCollector {
|
||||
pub(crate) fn new(size: u64) -> Self {
|
||||
// there will be size + 1 documents in docs at max
|
||||
@@ -79,28 +82,37 @@ impl CocoSearchSource {
|
||||
CocoSearchSource { server, client }
|
||||
}
|
||||
|
||||
fn build_request_from_query(&self, query: &SearchQuery) -> RequestBuilder {
|
||||
async fn build_request_from_query(
|
||||
&self,
|
||||
query: &SearchQuery,
|
||||
) -> Result<RequestBuilder, String> {
|
||||
self.build_request(query.from, query.size, &query.query_strings)
|
||||
.await
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
async fn build_request(
|
||||
&self,
|
||||
from: u64,
|
||||
size: u64,
|
||||
query_strings: &HashMap<String, String>,
|
||||
) -> RequestBuilder {
|
||||
) -> Result<RequestBuilder, String> {
|
||||
let url = HttpClient::join_url(&self.server.endpoint, "/query/_search");
|
||||
let mut request_builder = self.client.request(Method::GET, url);
|
||||
|
||||
if !self.server.public {
|
||||
if let Some(token) = get_server_token(&self.server.id).map(|t| t.access_token) {
|
||||
if let Some(token) = get_server_token(&self.server.id)
|
||||
.await?
|
||||
.map(|t| t.access_token)
|
||||
{
|
||||
request_builder = request_builder.header("X-API-TOKEN", token);
|
||||
}
|
||||
}
|
||||
|
||||
request_builder
|
||||
let result = request_builder
|
||||
.query(&[("from", &from.to_string()), ("size", &size.to_string())])
|
||||
.query(query_strings)
|
||||
.query(query_strings);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +130,7 @@ impl SearchSource for CocoSearchSource {
|
||||
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);
|
||||
let request_builder = self.build_request_from_query(&query).await.unwrap();
|
||||
|
||||
// Send the HTTP request asynchronously
|
||||
let response = request_builder.send().await;
|
||||
|
||||
@@ -24,6 +24,7 @@ lazy_static! {
|
||||
Arc::new(RwLock::new(HashMap::new()));
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn check_server_exists(id: &str) -> bool {
|
||||
let cache = SERVER_CACHE.read().unwrap(); // Acquire read lock
|
||||
cache.contains_key(id)
|
||||
@@ -35,9 +36,10 @@ pub fn get_server_by_id(id: &str) -> Option<Server> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_server_token(id: &str) -> Option<ServerAccessToken> {
|
||||
let cache = SERVER_TOKEN.read().unwrap(); // Acquire read lock
|
||||
cache.get(id).cloned()
|
||||
pub async fn get_server_token(id: &str) -> Result<Option<ServerAccessToken>, String> {
|
||||
let cache = SERVER_TOKEN.read().map_err(|err| err.to_string())?;
|
||||
|
||||
Ok(cache.get(id).cloned())
|
||||
}
|
||||
|
||||
pub fn save_access_token(server_id: String, token: ServerAccessToken) -> bool {
|
||||
@@ -132,6 +134,7 @@ fn get_default_server() -> Server {
|
||||
version: Version {
|
||||
number: "1.0.0_SNAPSHOT".to_string(),
|
||||
},
|
||||
minimal_client_version: None,
|
||||
updated: "2025-01-24T12:12:17.326286927+08:00".to_string(),
|
||||
public: false,
|
||||
available: true,
|
||||
@@ -259,7 +262,6 @@ pub async fn load_or_insert_default_server<R: Runtime>(
|
||||
pub async fn list_coco_servers<R: Runtime>(
|
||||
_app_handle: AppHandle<R>,
|
||||
) -> Result<Vec<Server>, String> {
|
||||
|
||||
//hard fresh all server's info, in order to get the actual health
|
||||
refresh_all_coco_server_info(_app_handle.clone()).await;
|
||||
|
||||
@@ -267,6 +269,7 @@ pub async fn list_coco_servers<R: Runtime>(
|
||||
Ok(servers)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_servers_as_hashmap() -> HashMap<String, Server> {
|
||||
let cache = SERVER_CACHE.read().unwrap();
|
||||
cache.clone()
|
||||
@@ -282,9 +285,7 @@ pub const COCO_SERVERS: &str = "coco_servers";
|
||||
|
||||
const COCO_SERVER_TOKENS: &str = "coco_server_tokens";
|
||||
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
) {
|
||||
pub async fn refresh_all_coco_server_info<R: Runtime>(app_handle: AppHandle<R>) {
|
||||
let servers = get_all_servers();
|
||||
for server in servers {
|
||||
let _ = refresh_coco_server_info(app_handle.clone(), server.id.clone()).await;
|
||||
@@ -334,7 +335,6 @@ pub async fn refresh_coco_server_info<R: Runtime>(
|
||||
|
||||
let _ = get_datasources_by_server(&id).await;
|
||||
|
||||
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => Err(format!("Failed to deserialize the response: {:?}", e)),
|
||||
@@ -407,9 +407,8 @@ pub async fn add_coco_server<R: Runtime>(
|
||||
// Save the new server to the cache
|
||||
save_server(&server);
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
// Persist the servers to the store
|
||||
persist_servers(&app_handle)
|
||||
@@ -459,9 +458,8 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
server.enabled = true;
|
||||
save_server(&server);
|
||||
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
// Register the server to the search source
|
||||
try_register_server_to_search_source(app_handle.clone(), &server).await;
|
||||
|
||||
persist_servers(&app_handle)
|
||||
.await
|
||||
@@ -470,6 +468,16 @@ pub async fn enable_server<R: Runtime>(app_handle: AppHandle<R>, id: String) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn try_register_server_to_search_source(
|
||||
app_handle: AppHandle<impl Runtime>,
|
||||
server: &Server,
|
||||
) {
|
||||
if server.enabled {
|
||||
let registry = app_handle.state::<SearchSourceRegistry>();
|
||||
let source = CocoSearchSource::new(server.clone(), Client::new());
|
||||
registry.register_source(source).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_server_as_offline(id: &str) {
|
||||
// println!("server_is_offline: {}", id);
|
||||
@@ -508,7 +516,7 @@ pub async fn logout_coco_server<R: Runtime>(
|
||||
dbg!("Attempting to log out server by id:", &id);
|
||||
|
||||
// Check if server token exists
|
||||
if let Some(_token) = get_server_token(id.as_str()) {
|
||||
if let Some(_token) = get_server_token(id.as_str()).await? {
|
||||
dbg!("Found server token for id:", &id);
|
||||
|
||||
// Remove the server token from cache
|
||||
@@ -584,6 +592,7 @@ fn test_trim_endpoint_last_forward_slash() {
|
||||
version: Version {
|
||||
number: "".to_string(),
|
||||
},
|
||||
minimal_client_version: None,
|
||||
updated: "".to_string(),
|
||||
public: false,
|
||||
available: false,
|
||||
|
||||
41
src-tauri/src/server/transcription.rs
Normal file
41
src-tauri/src/server/transcription.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::server::http_client::HttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::HashMap;
|
||||
use tauri::command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TranscriptionResponse {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn transcription(
|
||||
server_id: String,
|
||||
audio_type: String,
|
||||
audio_content: String,
|
||||
) -> Result<TranscriptionResponse, String> {
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("type".to_string(), JsonValue::String(audio_type));
|
||||
query_params.insert("content".to_string(), JsonValue::String(audio_content));
|
||||
|
||||
let response = HttpClient::post(
|
||||
&server_id,
|
||||
"/services/audio/transcription",
|
||||
Some(query_params),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json::<TranscriptionResponse>()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Transcription failed with status: {}",
|
||||
response.status()
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -1,182 +1,130 @@
|
||||
use crate::server::servers::{get_server_by_id, get_server_token};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use http::{HeaderMap, HeaderName, HeaderValue};
|
||||
use futures::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Error;
|
||||
use tokio_tungstenite::tungstenite::Error as WsError;
|
||||
use tokio_tungstenite::{
|
||||
connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WebSocketManager {
|
||||
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
|
||||
cancel_tx: Arc<Mutex<Option<mpsc::Sender<()>>>>,
|
||||
connections: Arc<Mutex<HashMap<String, Arc<WebSocketInstance>>>>,
|
||||
}
|
||||
|
||||
struct WebSocketInstance {
|
||||
ws_connection: Mutex<WebSocketStream<MaybeTlsStream<TcpStream>>>, // No need to lock the entire map
|
||||
cancel_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
// Function to convert the HTTP endpoint to WebSocket endpoint
|
||||
fn convert_to_websocket(endpoint: &str) -> Result<String, String> {
|
||||
let url = url::Url::parse(endpoint).map_err(|e| format!("Invalid URL: {}", e))?;
|
||||
let ws_protocol = if url.scheme() == "https" { "wss://" } else { "ws://" };
|
||||
let host = url.host_str().ok_or("No host found in URL")?;
|
||||
let port = url.port_or_known_default().unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
// Determine WebSocket protocol based on the scheme
|
||||
let ws_protocol = if url.scheme() == "https" {
|
||||
"wss://"
|
||||
} else {
|
||||
"ws://"
|
||||
};
|
||||
|
||||
// Extract host and port (if present)
|
||||
let host = url.host_str().ok_or_else(|| "No host found in URL")?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.unwrap_or(if url.scheme() == "https" { 443 } else { 80 });
|
||||
|
||||
// Build WebSocket URL, include the port if not the default
|
||||
let ws_endpoint = if port == 80 || port == 443 {
|
||||
format!("{}{}{}", ws_protocol, host, "/ws")
|
||||
} else {
|
||||
format!("{}{}:{}/ws", ws_protocol, host, port)
|
||||
};
|
||||
|
||||
Ok(ws_endpoint)
|
||||
}
|
||||
|
||||
// Function to build a HeaderMap from a vector of key-value pairs
|
||||
fn build_header_map(headers: Vec<(String, String)>) -> Result<HeaderMap, String> {
|
||||
let mut header_map = HeaderMap::new();
|
||||
for (key, value) in headers {
|
||||
let header_name = HeaderName::from_bytes(key.as_bytes())
|
||||
.map_err(|e| format!("Invalid header name: {}", e))?;
|
||||
let header_value =
|
||||
HeaderValue::from_str(&value).map_err(|e| format!("Invalid header value: {}", e))?;
|
||||
header_map.insert(header_name, header_value);
|
||||
}
|
||||
Ok(header_map)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect_to_server(
|
||||
id: String,
|
||||
client_id: String,
|
||||
state: tauri::State<'_, WebSocketManager>,
|
||||
app_handle: tauri::AppHandle,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
// Disconnect any existing connection first
|
||||
disconnect(state.clone()).await?;
|
||||
let connections_clone = state.connections.clone();
|
||||
|
||||
// Retrieve server details
|
||||
let server =
|
||||
get_server_by_id(id.as_str()).ok_or_else(|| format!("Server with ID {} not found", id))?;
|
||||
let endpoint = convert_to_websocket(server.endpoint.as_str())?;
|
||||
// Disconnect old connection first
|
||||
disconnect(client_id.clone(), state.clone()).await.ok();
|
||||
|
||||
// Retrieve the token for the server (token is optional)
|
||||
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
|
||||
let server = get_server_by_id(&id).ok_or(format!("Server with ID {} not found", id))?;
|
||||
let endpoint = convert_to_websocket(&server.endpoint)?;
|
||||
let token = get_server_token(&id).await?.map(|t| t.access_token.clone());
|
||||
|
||||
// Create the WebSocket request
|
||||
let mut request =
|
||||
tokio_tungstenite::tungstenite::client::IntoClientRequest::into_client_request(&endpoint)
|
||||
.map_err(|e| format!("Failed to create WebSocket request: {}", e))?;
|
||||
|
||||
// Add necessary headers
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Connection", "Upgrade".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Upgrade", "websocket".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
||||
request.headers_mut().insert("Connection", "Upgrade".parse().unwrap());
|
||||
request.headers_mut().insert("Upgrade", "websocket".parse().unwrap());
|
||||
request.headers_mut().insert("Sec-WebSocket-Version", "13".parse().unwrap());
|
||||
request.headers_mut().insert("Sec-WebSocket-Key", generate_key().parse().unwrap());
|
||||
|
||||
// If a token exists, add it to the headers
|
||||
if let Some(token) = token {
|
||||
request
|
||||
.headers_mut()
|
||||
.insert("X-API-TOKEN", token.parse().unwrap());
|
||||
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
|
||||
}
|
||||
|
||||
// Establish the WebSocket connection
|
||||
// dbg!(&request);
|
||||
let (mut ws_remote, _) = connect_async(request).await.map_err(|e| match e {
|
||||
Error::ConnectionClosed => "WebSocket connection was closed".to_string(),
|
||||
Error::Protocol(protocol_error) => format!("Protocol error: {}", protocol_error),
|
||||
Error::Utf8 => "UTF-8 error in WebSocket data".to_string(),
|
||||
_ => format!("Unknown error: {:?}", e),
|
||||
})?;
|
||||
|
||||
// Create cancellation channel
|
||||
let (ws_stream, _) = connect_async(request).await.map_err(|e| format!("WebSocket error: {:?}", e))?;
|
||||
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
|
||||
|
||||
// Store connection and cancellation sender
|
||||
*state.ws_connection.lock().await = Some(ws_remote);
|
||||
*state.cancel_tx.lock().await = Some(cancel_tx);
|
||||
// Spawn listener task with cancellation
|
||||
let instance = Arc::new(WebSocketInstance {
|
||||
ws_connection: Mutex::new(ws_stream),
|
||||
cancel_tx,
|
||||
});
|
||||
|
||||
// Insert connection into the map (lock is held briefly)
|
||||
{
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.insert(client_id.clone(), instance.clone());
|
||||
}
|
||||
|
||||
// Spawn WebSocket handler in a separate task
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let connection_clone = state.ws_connection.clone();
|
||||
let client_id_clone = client_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut connection = connection_clone.lock().await;
|
||||
if let Some(ws) = connection.as_mut() {
|
||||
let ws = &mut *instance.ws_connection.lock().await;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ws.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
//println!("Received message: {}", text);
|
||||
let _ = app_handle_clone.emit("ws-message", text);
|
||||
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
|
||||
},
|
||||
Some(Err(WsError::ConnectionClosed)) => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
eprintln!("WebSocket connection closed by the server.");
|
||||
Some(Err(_)) | None => {
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
},
|
||||
Some(Err(WsError::Protocol(e))) => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
eprintln!("Protocol error: {}", e);
|
||||
break;
|
||||
},
|
||||
Some(Err(WsError::Utf8)) => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
eprintln!("Received invalid UTF-8 data.");
|
||||
break;
|
||||
},
|
||||
Some(Err(_)) => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
eprintln!("WebSocket error encountered.");
|
||||
break;
|
||||
},
|
||||
_ => continue,
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ = cancel_rx.recv() => {
|
||||
let _ = app_handle_clone.emit("ws-error", id);
|
||||
dbg!("Cancelling WebSocket connection");
|
||||
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove connection after it closes
|
||||
let mut connections = connections_clone.lock().await;
|
||||
connections.remove(&client_id_clone);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||
// Send cancellation signal
|
||||
if let Some(cancel_tx) = state.cancel_tx.lock().await.take() {
|
||||
let _ = cancel_tx.send(()).await;
|
||||
}
|
||||
|
||||
// Close connection
|
||||
let mut connection = state.ws_connection.lock().await;
|
||||
if let Some(mut ws) = connection.take() {
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(client_id: String, state: tauri::State<'_, WebSocketManager>) -> Result<(), String> {
|
||||
let instance = {
|
||||
let mut connections = state.connections.lock().await;
|
||||
connections.remove(&client_id)
|
||||
};
|
||||
|
||||
if let Some(instance) = instance {
|
||||
let _ = instance.cancel_tx.send(()).await;
|
||||
|
||||
// Close WebSocket (lock only the connection, not the whole map)
|
||||
let mut ws = instance.ws_connection.lock().await;
|
||||
let _ = ws.close(None).await;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
use crate::{move_window_to_active_monitor, COCO_TAURI_STORE};
|
||||
use tauri::App;
|
||||
use tauri::AppHandle;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri_plugin_global_shortcut::GlobalShortcutExt;
|
||||
use tauri_plugin_global_shortcut::Shortcut;
|
||||
use tauri_plugin_global_shortcut::ShortcutState;
|
||||
use tauri_plugin_store::JsonValue;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use crate::{hide_coco, show_coco, COCO_TAURI_STORE};
|
||||
use tauri::{async_runtime, App, AppHandle, Manager, Runtime};
|
||||
use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};
|
||||
use tauri_plugin_store::{JsonValue, StoreExt};
|
||||
|
||||
/// Tauri's store is a key-value database, we use it to store our registered
|
||||
/// global shortcut.
|
||||
@@ -54,14 +48,14 @@ pub fn enable_shortcut(app: &App) {
|
||||
/// Get the stored shortcut as a string, same as [`_get_shortcut()`], except that
|
||||
/// this is a `tauri::command` interface.
|
||||
#[tauri::command]
|
||||
pub fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
pub async fn get_current_shortcut<R: Runtime>(app: AppHandle<R>) -> Result<String, String> {
|
||||
let shortcut = _get_shortcut(&app);
|
||||
Ok(shortcut)
|
||||
}
|
||||
|
||||
/// Get the current shortcut and unregister it on the tauri side.
|
||||
#[tauri::command]
|
||||
pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
pub async fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
let shortcut_str = _get_shortcut(&app);
|
||||
let shortcut = shortcut_str
|
||||
.parse::<Shortcut>()
|
||||
@@ -74,7 +68,7 @@ pub fn unregister_shortcut<R: Runtime>(app: AppHandle<R>) {
|
||||
|
||||
/// Change the global shortcut to `key`.
|
||||
#[tauri::command]
|
||||
pub fn change_shortcut<R: Runtime>(
|
||||
pub async fn change_shortcut<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
_window: tauri::Window<R>,
|
||||
key: String,
|
||||
@@ -105,16 +99,15 @@ fn _register_shortcut<R: Runtime>(app: &AppHandle<R>, shortcut: Shortcut) {
|
||||
dbg!("shortcut pressed");
|
||||
let main_window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
if main_window.is_visible().unwrap() {
|
||||
dbg!("hiding window");
|
||||
main_window.hide().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
hide_coco(app_handle).await;
|
||||
});
|
||||
} else {
|
||||
dbg!("showing window");
|
||||
move_window_to_active_monitor(&main_window);
|
||||
main_window.set_visible_on_all_workspaces(true).unwrap();
|
||||
main_window.set_always_on_top(true).unwrap();
|
||||
main_window.set_focus().unwrap();
|
||||
main_window.show().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
show_coco(app_handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,15 +128,16 @@ fn _register_shortcut_upon_start(app: &App, shortcut: Shortcut) {
|
||||
if scut == &shortcut {
|
||||
let window = app.get_window(MAIN_WINDOW_LABEL).unwrap();
|
||||
if let ShortcutState::Pressed = event.state() {
|
||||
let app_handle = app.clone();
|
||||
|
||||
if window.is_visible().unwrap() {
|
||||
window.hide().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
hide_coco(app_handle).await;
|
||||
});
|
||||
} else {
|
||||
// dbg!("showing window");
|
||||
move_window_to_active_monitor(&window);
|
||||
window.set_visible_on_all_workspaces(true).unwrap();
|
||||
window.set_always_on_top(true).unwrap();
|
||||
window.set_focus().unwrap();
|
||||
window.show().unwrap();
|
||||
async_runtime::spawn(async move {
|
||||
show_coco(app_handle).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
"windowEffects": {
|
||||
"effects": [],
|
||||
"radius": 12
|
||||
}
|
||||
},
|
||||
"visibleOnAllWorkspaces": true,
|
||||
"alwaysOnTop": true
|
||||
},
|
||||
{
|
||||
"label": "settings",
|
||||
@@ -113,7 +115,7 @@
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlDRjNDRUU0NTdBMzdCRTMKUldUamU2Tlg1TTd6bkUwZWM0d2Zjdk0wdXJmendWVlpMMmhKN25EcmprYmIydnJ3dmFUME9QYXkK",
|
||||
"endpoints": [
|
||||
"https://api.coco.rs/update/{{target}}/{{arch}}/{{current_version}}"
|
||||
"https://release.infinilabs.com/coco/app/.latest.json?target={{target}}&arch={{arch}}¤t_version={{current_version}}"
|
||||
]
|
||||
},
|
||||
"websocket": {},
|
||||
|
||||
73
src/api/attachment.ts
Normal file
73
src/api/attachment.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface UploadAttachmentPayload {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
filePaths: string[];
|
||||
}
|
||||
|
||||
interface UploadAttachmentResponse {
|
||||
acknowledged: boolean;
|
||||
attachments: string[];
|
||||
}
|
||||
|
||||
type GetAttachmentPayload = Omit<UploadAttachmentPayload, "filePaths">;
|
||||
|
||||
export interface AttachmentHit {
|
||||
_index: string;
|
||||
_type: string;
|
||||
_id: string;
|
||||
_score: number;
|
||||
_source: {
|
||||
id: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
session: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetAttachmentResponse {
|
||||
took: number;
|
||||
timed_out: boolean;
|
||||
_shards: {
|
||||
total: number;
|
||||
successful: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
relation: string;
|
||||
};
|
||||
max_score: number;
|
||||
hits: AttachmentHit[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteAttachmentPayload {
|
||||
serverId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const uploadAttachment = async (payload: UploadAttachmentPayload) => {
|
||||
const response = await invoke<UploadAttachmentResponse>("upload_attachment", {
|
||||
...payload,
|
||||
});
|
||||
|
||||
if (response?.acknowledged) {
|
||||
return response.attachments;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAttachment = (payload: GetAttachmentPayload) => {
|
||||
return invoke<GetAttachmentResponse>("get_attachment", { ...payload });
|
||||
};
|
||||
|
||||
export const deleteAttachment = (payload: DeleteAttachmentPayload) => {
|
||||
return invoke<boolean>("delete_attachment", { ...payload });
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
import { fetch } from "@tauri-apps/plugin-http";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { clientEnv } from "@/utils/env";
|
||||
import { useLogStore } from "@/stores/logStore";
|
||||
|
||||
import { get_server_token } from "@/commands";
|
||||
interface FetchRequestConfig {
|
||||
url: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
@@ -63,7 +62,7 @@ export const tauriFetch = async <T = any>({
|
||||
}
|
||||
|
||||
const server_id = connectStore.state?.currentService?.id || "default_coco_server"
|
||||
const res: any = await invoke("get_server_token", {id: server_id});
|
||||
const res: any = await get_server_token(server_id);
|
||||
|
||||
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;
|
||||
|
||||
|
||||
15
src/api/transcription.ts
Normal file
15
src/api/transcription.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface TranscriptionPayload {
|
||||
serverId: string;
|
||||
audioType: string;
|
||||
audioContent: string;
|
||||
}
|
||||
|
||||
interface TranscriptionResponse {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const transcription = (payload: TranscriptionPayload) => {
|
||||
return invoke<TranscriptionResponse>("transcription", { ...payload });
|
||||
};
|
||||
2
src/commands/index.ts
Normal file
2
src/commands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './servers';
|
||||
export * from './system';
|
||||
182
src/commands/servers.ts
Normal file
182
src/commands/servers.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
import { ServerTokenResponse, Server, Connector, DataSource, GetResponse } from "@/types/commands"
|
||||
|
||||
export function get_server_token(id: string): Promise<ServerTokenResponse> {
|
||||
return invoke(`get_server_token`, { id });
|
||||
}
|
||||
|
||||
export function list_coco_servers(): Promise<Server[]> {
|
||||
return invoke(`list_coco_servers`);
|
||||
}
|
||||
|
||||
export function add_coco_server(endpoint: string): Promise<Server> {
|
||||
return invoke(`add_coco_server`, { endpoint });
|
||||
}
|
||||
|
||||
export function enable_server(id: string): Promise<void> {
|
||||
return invoke(`enable_server`, { id });
|
||||
}
|
||||
|
||||
export function disable_server(id: string): Promise<void> {
|
||||
return invoke(`disable_server`, { id });
|
||||
}
|
||||
|
||||
export function remove_coco_server(id: string): Promise<void> {
|
||||
return invoke(`remove_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function logout_coco_server(id: string): Promise<void> {
|
||||
return invoke(`logout_coco_server`, { id });
|
||||
}
|
||||
|
||||
export function refresh_coco_server_info(id: string): Promise<Server> {
|
||||
return invoke(`refresh_coco_server_info`, { id });
|
||||
}
|
||||
|
||||
export function handle_sso_callback({
|
||||
serverId,
|
||||
requestId,
|
||||
code,
|
||||
}: {
|
||||
serverId: string;
|
||||
requestId: string;
|
||||
code: string;
|
||||
}): Promise<void> {
|
||||
return invoke(`handle_sso_callback`, {
|
||||
serverId,
|
||||
requestId,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
export function get_connectors_by_server(id: string): Promise<Connector[]> {
|
||||
return invoke(`get_connectors_by_server`, { id });
|
||||
}
|
||||
|
||||
export function get_datasources_by_server(id: string): Promise<DataSource[]> {
|
||||
return invoke(`get_datasources_by_server`, { id });
|
||||
}
|
||||
|
||||
export function connect_to_server(id: string, clientId: string): Promise<void> {
|
||||
return invoke(`connect_to_server`, { id, clientId });
|
||||
}
|
||||
|
||||
export function disconnect(clientId: string): Promise<void> {
|
||||
return invoke(`disconnect`, { clientId });
|
||||
}
|
||||
|
||||
export function chat_history({
|
||||
serverId,
|
||||
from = 0,
|
||||
size = 20,
|
||||
}: {
|
||||
serverId: string;
|
||||
from?: number;
|
||||
size?: number;
|
||||
}): Promise<string> {
|
||||
return invoke(`chat_history`, {
|
||||
serverId,
|
||||
from,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
export function session_chat_history({
|
||||
serverId,
|
||||
sessionId,
|
||||
from = 0,
|
||||
size = 20,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
from?: number;
|
||||
size?: number;
|
||||
}): Promise<string> {
|
||||
return invoke(`session_chat_history`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
from,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
export function close_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`close_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function open_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`open_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function cancel_session_chat({
|
||||
serverId,
|
||||
sessionId,
|
||||
}: {
|
||||
serverId: string;
|
||||
sessionId: string;
|
||||
}): Promise<string> {
|
||||
return invoke(`cancel_session_chat`, {
|
||||
serverId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export function new_chat({
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId?: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<GetResponse> {
|
||||
return invoke(`new_chat`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export function send_message({
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
}: {
|
||||
serverId: string;
|
||||
websocketId?: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
queryParams?: Record<string, any>;
|
||||
}): Promise<string> {
|
||||
return invoke(`send_message`, {
|
||||
serverId,
|
||||
websocketId,
|
||||
sessionId,
|
||||
message,
|
||||
queryParams,
|
||||
});
|
||||
}
|
||||
29
src/commands/system.ts
Normal file
29
src/commands/system.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export function change_autostart(open: boolean): Promise<void> {
|
||||
return invoke('change_autostart', { open });
|
||||
}
|
||||
|
||||
export function get_current_shortcut(): Promise<string> {
|
||||
return invoke('get_current_shortcut');
|
||||
}
|
||||
|
||||
export function change_shortcut(key: string): Promise<void> {
|
||||
return invoke('change_shortcut', { key });
|
||||
}
|
||||
|
||||
export function unregister_shortcut(): Promise<void> {
|
||||
return invoke('unregister_shortcut');
|
||||
}
|
||||
|
||||
export function hide_coco(): Promise<void> {
|
||||
return invoke('hide_coco');
|
||||
}
|
||||
|
||||
export function show_coco(): Promise<void> {
|
||||
return invoke('show_coco');
|
||||
}
|
||||
|
||||
export function show_settings(): Promise<void> {
|
||||
return invoke('show_settings');
|
||||
}
|
||||
@@ -4,27 +4,23 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import type { Chat } from "./types";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import FileList from "@/components/Search/FileList";
|
||||
import { Greetings } from "./Greetings";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import { useWindows } from "@/hooks/useWindows";
|
||||
import useMessageChunkData from "@/hooks/useMessageChunkData";
|
||||
import useWebSocket from "@/hooks/useWebSocket";
|
||||
import { useChatActions } from "@/hooks/useChatActions";
|
||||
import { useMessageHandler } from "@/hooks/useMessageHandler";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { ChatHeader } from "./ChatHeader";
|
||||
import { ChatContent } from "./ChatContent";
|
||||
import ConnectPrompt from "./ConnectPrompt";
|
||||
import type { Chat } from "./types";
|
||||
|
||||
interface ChatAIProps {
|
||||
isTransitioned: boolean;
|
||||
@@ -36,6 +32,7 @@ interface ChatAIProps {
|
||||
isSidebarOpen?: boolean;
|
||||
clearChatPage?: () => void;
|
||||
isChatPage?: boolean;
|
||||
getFileUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export interface ChatAIRef {
|
||||
@@ -58,22 +55,19 @@ const ChatAI = memo(
|
||||
isSidebarOpen = false,
|
||||
clearChatPage,
|
||||
isChatPage = false,
|
||||
getFileUrl,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
if (!isTransitioned) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
init: init,
|
||||
cancelChat: cancelChat,
|
||||
cancelChat: () => cancelChat(activeChat),
|
||||
reconnect: reconnect,
|
||||
clearChat: clearChat,
|
||||
}));
|
||||
|
||||
const { createWin } = useWindows();
|
||||
|
||||
const { curChatEnd, setCurChatEnd, connected, setConnected } =
|
||||
useChatStore();
|
||||
|
||||
@@ -81,25 +75,26 @@ const ChatAI = memo(
|
||||
|
||||
const [activeChat, setActiveChat] = useState<Chat>();
|
||||
const [timedoutShow, setTimedoutShow] = useState(false);
|
||||
const [IsLogin, setIsLogin] = useState(true);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
|
||||
const curIdRef = useRef("");
|
||||
|
||||
const [isSidebarOpenChat, setIsSidebarOpenChat] = useState(isSidebarOpen);
|
||||
const [chats, setChats] = useState<Chat[]>([]);
|
||||
const sourceDataIds = useSearchStore((state) => state.sourceDataIds);
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
|
||||
useEffect(() => {
|
||||
activeChatProp && setActiveChat(activeChatProp);
|
||||
}, [activeChatProp]);
|
||||
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const [Question, setQuestion] = useState<string>("");
|
||||
|
||||
const [websocketSessionId, setWebsocketSessionId] = useState('');
|
||||
|
||||
const onWebsocketSessionId = useCallback((sessionId: string) => {
|
||||
setWebsocketSessionId(sessionId);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: {
|
||||
query_intent,
|
||||
@@ -122,385 +117,130 @@ const ChatAI = memo(
|
||||
response: false,
|
||||
});
|
||||
|
||||
const dealMsg = useCallback(
|
||||
(msg: string) => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
|
||||
|
||||
if (!msg.includes("PRIVATE")) return;
|
||||
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
if (!curChatEnd) {
|
||||
console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
cancelChat();
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
if (msg.includes("assistant finished output")) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
console.log("AI finished output");
|
||||
setCurChatEnd(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanedData = msg.replace(/^PRIVATE /, "");
|
||||
try {
|
||||
const chunkData = JSON.parse(cleanedData);
|
||||
|
||||
if (chunkData.reply_to_message !== curIdRef.current) return;
|
||||
|
||||
setLoadingStep(() => ({
|
||||
query_intent: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
think: false,
|
||||
response: false,
|
||||
[chunkData.chunk_type]: true,
|
||||
}));
|
||||
|
||||
// ['query_intent', 'fetch_source', 'pick_source', 'deep_read', 'think', 'response'];
|
||||
if (chunkData.chunk_type === "query_intent") {
|
||||
handlers.deal_query_intent(chunkData);
|
||||
} else if (chunkData.chunk_type === "fetch_source") {
|
||||
handlers.deal_fetch_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "pick_source") {
|
||||
handlers.deal_pick_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "deep_read") {
|
||||
handlers.deal_deep_read(chunkData);
|
||||
} else if (chunkData.chunk_type === "think") {
|
||||
handlers.deal_think(chunkData);
|
||||
} else if (chunkData.chunk_type === "response") {
|
||||
handlers.deal_response(chunkData);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("parse error:", error);
|
||||
}
|
||||
},
|
||||
[curChatEnd]
|
||||
);
|
||||
|
||||
const { errorShow, setErrorShow, reconnect } = useWebSocket({
|
||||
const clientId = isChatPage ? "standalone" : "popup"
|
||||
const { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg } =
|
||||
useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsg,
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
});
|
||||
|
||||
const updatedChat = useMemo(() => {
|
||||
if (!activeChat?._id) return null;
|
||||
return {
|
||||
...activeChat,
|
||||
messages: [...(activeChat.messages || [])],
|
||||
};
|
||||
}, [activeChat]);
|
||||
const {
|
||||
chatClose,
|
||||
cancelChat,
|
||||
chatHistory,
|
||||
createNewChat,
|
||||
handleSendMessage,
|
||||
openSessionChat,
|
||||
getChatHistory,
|
||||
createChatWindow,
|
||||
} = useChatActions(
|
||||
currentService?.id,
|
||||
setActiveChat,
|
||||
setCurChatEnd,
|
||||
setErrorShow,
|
||||
setTimedoutShow,
|
||||
clearAllChunkData,
|
||||
setQuestion,
|
||||
curIdRef,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
sourceDataIds,
|
||||
changeInput,
|
||||
websocketSessionId
|
||||
);
|
||||
|
||||
const simulateAssistantResponse = useCallback(() => {
|
||||
if (!updatedChat) return;
|
||||
|
||||
// console.log("updatedChat:", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
}, [updatedChat]);
|
||||
|
||||
useEffect(() => {
|
||||
if (curChatEnd) {
|
||||
simulateAssistantResponse();
|
||||
}
|
||||
}, [curChatEnd]);
|
||||
|
||||
const [userScrolling, setUserScrolling] = useState(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
debounce(() => {
|
||||
if (!userScrolling) {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 100),
|
||||
[userScrolling]
|
||||
const { dealMsg, messageTimeoutRef } = useMessageHandler(
|
||||
curIdRef,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
(chat) => cancelChat(chat || activeChat),
|
||||
setLoadingStep,
|
||||
handlers,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
if (dealMsg) {
|
||||
dealMsgRef.current = dealMsg;
|
||||
updateDealMsg && updateDealMsg(dealMsg);
|
||||
}
|
||||
}, [dealMsg, updateDealMsg]);
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const isAtBottom =
|
||||
Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
|
||||
|
||||
setUserScrolling(!isAtBottom);
|
||||
|
||||
if (isAtBottom) {
|
||||
setUserScrolling(false);
|
||||
}
|
||||
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
const {
|
||||
scrollTop: newScrollTop,
|
||||
scrollHeight: newScrollHeight,
|
||||
clientHeight: newClientHeight,
|
||||
} = container;
|
||||
const nowAtBottom =
|
||||
Math.abs(newScrollHeight - newScrollTop - newClientHeight) < 10;
|
||||
if (nowAtBottom) {
|
||||
setUserScrolling(false);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [
|
||||
activeChat?.messages,
|
||||
query_intent?.message_chunk,
|
||||
fetch_source?.message_chunk,
|
||||
pick_source?.message_chunk,
|
||||
deep_read?.message_chunk,
|
||||
think?.message_chunk,
|
||||
response?.message_chunk,
|
||||
]);
|
||||
|
||||
const clearChat = () => {
|
||||
const clearChat = useCallback(() => {
|
||||
console.log("clearChat");
|
||||
chatClose();
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
clearChatPage && clearChatPage();
|
||||
};
|
||||
}, [
|
||||
activeChat,
|
||||
chatClose,
|
||||
clearChatPage,
|
||||
setCurChatEnd,
|
||||
setErrorShow,
|
||||
setTimedoutShow,
|
||||
]);
|
||||
|
||||
const createNewChat = useCallback(
|
||||
async (value: string = "") => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose();
|
||||
clearAllChunkData();
|
||||
setQuestion(value);
|
||||
try {
|
||||
console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("new_chat", {
|
||||
serverId: currentService?.id,
|
||||
message: value,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
});
|
||||
console.log("_new", response);
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
|
||||
newChat._source = {
|
||||
message: value,
|
||||
};
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [newChat],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
//console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("createNewChat:", error);
|
||||
}
|
||||
},
|
||||
[currentService?.id, sourceDataIds, isSearchActive, isDeepThinkActive]
|
||||
);
|
||||
|
||||
const init = (value: string) => {
|
||||
if (!IsLogin) return;
|
||||
const init = useCallback(
|
||||
(value: string) => {
|
||||
if (!isLogin) return;
|
||||
if (!curChatEnd) return;
|
||||
if (!activeChat?._id) {
|
||||
createNewChat(value);
|
||||
createNewChat(value, activeChat, websocketSessionId);
|
||||
} else {
|
||||
handleSendMessage(value);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, newChat: Chat) => {
|
||||
if (!newChat?._id || !content) return;
|
||||
|
||||
try {
|
||||
//console.log("sourceDataIds", sourceDataIds);
|
||||
let response: any = await invoke("send_message", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: newChat?._id,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds.join(","),
|
||||
},
|
||||
message: content,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_send", response);
|
||||
curIdRef.current = response[0]?._id;
|
||||
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [...(newChat?.messages || []), ...(response || [])],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
//console.log("updatedChat2", updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("sendMessage:", error);
|
||||
handleSendMessage(value, activeChat, websocketSessionId);
|
||||
}
|
||||
},
|
||||
[
|
||||
JSON.stringify(activeChat?.messages),
|
||||
currentService?.id,
|
||||
sourceDataIds,
|
||||
isSearchActive,
|
||||
isDeepThinkActive,
|
||||
]
|
||||
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage, websocketSessionId]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, newChat?: Chat) => {
|
||||
newChat = newChat || activeChat;
|
||||
if (!newChat?._id || !content) return;
|
||||
setQuestion(content);
|
||||
await chatHistory(newChat, (chat) => sendMessage(content, chat));
|
||||
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
clearAllChunkData();
|
||||
},
|
||||
[activeChat, sendMessage]
|
||||
);
|
||||
|
||||
const chatClose = async () => {
|
||||
if (!activeChat?._id) return;
|
||||
try {
|
||||
let response: any = await invoke("close_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: activeChat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_close", response);
|
||||
} catch (error) {
|
||||
console.error("chatClose:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelChat = async () => {
|
||||
setCurChatEnd(true);
|
||||
if (!activeChat?._id) return;
|
||||
try {
|
||||
let response: any = await invoke("cancel_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: activeChat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_cancel", response);
|
||||
} catch (error) {
|
||||
console.error("cancelChat:", error);
|
||||
}
|
||||
};
|
||||
|
||||
async function openChatAI() {
|
||||
if (isTauri()) {
|
||||
createWin &&
|
||||
createWin({
|
||||
label: "chat",
|
||||
title: "Coco Chat",
|
||||
dragDropEnabled: true,
|
||||
center: true,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
decorations: true,
|
||||
closable: true,
|
||||
url: "/ui/chat",
|
||||
});
|
||||
}
|
||||
}
|
||||
const { createWin } = useWindows();
|
||||
const openChatAI = useCallback(() => {
|
||||
createChatWindow(createWin);
|
||||
}, [createChatWindow, createWin]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
chatClose();
|
||||
chatClose(activeChat);
|
||||
setActiveChat(undefined);
|
||||
setCurChatEnd(true);
|
||||
scrollToBottom.cancel();
|
||||
disconnectWS();
|
||||
};
|
||||
}, []);
|
||||
}, [chatClose, setCurChatEnd]);
|
||||
|
||||
const chatHistory = async (
|
||||
chat: Chat,
|
||||
callback?: (chat: Chat) => void
|
||||
) => {
|
||||
try {
|
||||
let response: any = await invoke("session_chat_history", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
from: 0,
|
||||
size: 20,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
const hits = response?.hits?.hits || [];
|
||||
const updatedChat: Chat = {
|
||||
...chat,
|
||||
messages: hits,
|
||||
};
|
||||
console.log("id_history", response, updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
callback && callback(updatedChat);
|
||||
} catch (error) {
|
||||
console.error("chatHistory:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectChat = async (chat: any) => {
|
||||
chatClose();
|
||||
const onSelectChat = useCallback(
|
||||
async (chat: Chat) => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
clearAllChunkData();
|
||||
try {
|
||||
let response: any = await invoke("open_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
sessionId: chat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_open", response);
|
||||
await cancelChat(activeChat);
|
||||
await chatClose(activeChat);
|
||||
const response = await openSessionChat(chat);
|
||||
if (response) {
|
||||
chatHistory(response);
|
||||
} catch (error) {
|
||||
console.error("open_session_chat:", error);
|
||||
}
|
||||
};
|
||||
},
|
||||
[
|
||||
clearAllChunkData,
|
||||
cancelChat,
|
||||
activeChat,
|
||||
chatClose,
|
||||
openSessionChat,
|
||||
chatHistory,
|
||||
]
|
||||
);
|
||||
|
||||
const deleteChat = (chatId: string) => {
|
||||
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);
|
||||
@@ -510,7 +250,7 @@ const ChatAI = memo(
|
||||
init("");
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [activeChat, chats, init, setActiveChat]);
|
||||
|
||||
const handleOutsideClick = useCallback((e: MouseEvent) => {
|
||||
const sidebar = document.querySelector("[data-sidebar]");
|
||||
@@ -534,104 +274,57 @@ const ChatAI = memo(
|
||||
};
|
||||
}, [isSidebarOpenChat, handleOutsideClick]);
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
if (!currentService?.id) return;
|
||||
try {
|
||||
let response: any = await invoke("chat_history", {
|
||||
serverId: currentService?.id,
|
||||
from: 0,
|
||||
size: 20,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
const fetchChatHistory = useCallback(async () => {
|
||||
const hits = await getChatHistory();
|
||||
setChats(hits);
|
||||
} catch (error) {
|
||||
console.error("chat_history:", error);
|
||||
}
|
||||
}, [currentService?.id]);
|
||||
}, [getChatHistory]);
|
||||
|
||||
const setIsLoginChat = useCallback(
|
||||
(value: boolean) => {
|
||||
setIsLogin(value);
|
||||
value && currentService && !setIsSidebarOpen && getChatHistory();
|
||||
value && currentService && !setIsSidebarOpen && fetchChatHistory();
|
||||
!value && setChats([]);
|
||||
},
|
||||
[currentService]
|
||||
[currentService, setIsSidebarOpen, fetchChatHistory]
|
||||
);
|
||||
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||
!isSidebarOpenChat && fetchChatHistory();
|
||||
}, [isSidebarOpenChat, setIsSidebarOpen, fetchChatHistory]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`h-full flex flex-col rounded-xl overflow-hidden`}
|
||||
>
|
||||
{setIsSidebarOpen ? null : (
|
||||
<div
|
||||
data-sidebar
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
|
||||
${
|
||||
isSidebarOpenChat
|
||||
? "translate-x-0"
|
||||
: "-translate-x-[calc(100%)]"
|
||||
}
|
||||
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden`}
|
||||
>
|
||||
<Sidebar
|
||||
{!setIsSidebarOpen && (
|
||||
<ChatSidebar
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={clearChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={deleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatHeader
|
||||
onCreateNewChat={clearChat}
|
||||
onOpenChatAI={openChatAI}
|
||||
setIsSidebarOpen={() => {
|
||||
setIsSidebarOpenChat(!isSidebarOpenChat);
|
||||
setIsSidebarOpen && setIsSidebarOpen(!isSidebarOpenChat);
|
||||
!isSidebarOpenChat && getChatHistory();
|
||||
}}
|
||||
setIsSidebarOpen={toggleSidebar}
|
||||
isSidebarOpen={isSidebarOpenChat}
|
||||
activeChat={activeChat}
|
||||
reconnect={reconnect}
|
||||
isChatPage={isChatPage}
|
||||
setIsLogin={setIsLoginChat}
|
||||
/>
|
||||
{IsLogin ? (
|
||||
<div className="flex flex-col h-full justify-between overflow-hidden">
|
||||
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
|
||||
<Greetings />
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
message={message}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
{(query_intent ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._id ? (
|
||||
<ChatMessage
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
{isLogin ? (
|
||||
<ChatContent
|
||||
activeChat={activeChat}
|
||||
curChatEnd={curChatEnd}
|
||||
query_intent={query_intent}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
@@ -639,47 +332,12 @@ const ChatAI = memo(
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
timedoutShow={timedoutShow}
|
||||
errorShow={errorShow}
|
||||
Question={Question}
|
||||
handleSendMessage={(value) => handleSendMessage(value, activeChat)}
|
||||
getFileUrl={getFileUrl}
|
||||
/>
|
||||
) : null}
|
||||
{timedoutShow ? (
|
||||
<ChatMessage
|
||||
key={"timedout"}
|
||||
message={{
|
||||
_id: "timedout",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
{errorShow ? (
|
||||
<ChatMessage
|
||||
key={"error"}
|
||||
message={{
|
||||
_id: "error",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.error"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{uploadFiles.length > 0 && (
|
||||
<div className="max-h-[120px] overflow-auto p-2">
|
||||
<FileList />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ConnectPrompt />
|
||||
)}
|
||||
|
||||
166
src/components/Assistant/ChatContent.tsx
Normal file
166
src/components/Assistant/ChatContent.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ChatMessage } from "@/components/ChatMessage";
|
||||
import { Greetings } from "./Greetings";
|
||||
import FileList from "@/components/Assistant/FileList";
|
||||
import { useChatScroll } from "@/hooks/useChatScroll";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import type { Chat, IChunkData } from "./types";
|
||||
import SessionFile from "./SessionFile";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
interface ChatContentProps {
|
||||
activeChat?: Chat;
|
||||
curChatEnd: boolean;
|
||||
query_intent?: IChunkData;
|
||||
fetch_source?: IChunkData;
|
||||
pick_source?: IChunkData;
|
||||
deep_read?: IChunkData;
|
||||
think?: IChunkData;
|
||||
response?: IChunkData;
|
||||
loadingStep?: Record<string, boolean>;
|
||||
timedoutShow: boolean;
|
||||
errorShow: boolean;
|
||||
Question: string;
|
||||
handleSendMessage: (content: string, newChat?: Chat) => void;
|
||||
getFileUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
export const ChatContent = ({
|
||||
activeChat,
|
||||
curChatEnd,
|
||||
query_intent,
|
||||
fetch_source,
|
||||
pick_source,
|
||||
deep_read,
|
||||
think,
|
||||
response,
|
||||
loadingStep,
|
||||
timedoutShow,
|
||||
errorShow,
|
||||
Question,
|
||||
handleSendMessage,
|
||||
getFileUrl,
|
||||
}: ChatContentProps) => {
|
||||
const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const setCurrentSessionId = useConnectStore((state) => {
|
||||
return state.setCurrentSessionId;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentSessionId(activeChat?._id);
|
||||
}, [activeChat]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { scrollToBottom } = useChatScroll(messagesEndRef);
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [
|
||||
activeChat?.messages,
|
||||
query_intent?.message_chunk,
|
||||
fetch_source?.message_chunk,
|
||||
pick_source?.message_chunk,
|
||||
deep_read?.message_chunk,
|
||||
think?.message_chunk,
|
||||
response?.message_chunk,
|
||||
curChatEnd,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scrollToBottom.cancel();
|
||||
};
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full justify-between overflow-hidden">
|
||||
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
|
||||
<Greetings />
|
||||
|
||||
{activeChat?.messages?.map((message, index) => (
|
||||
<ChatMessage
|
||||
key={message._id + index}
|
||||
message={message}
|
||||
isTyping={false}
|
||||
onResend={handleSendMessage}
|
||||
/>
|
||||
))}
|
||||
{(!curChatEnd ||
|
||||
query_intent ||
|
||||
fetch_source ||
|
||||
pick_source ||
|
||||
deep_read ||
|
||||
think ||
|
||||
response) &&
|
||||
activeChat?._id ? (
|
||||
<ChatMessage
|
||||
key={"current"}
|
||||
message={{
|
||||
_id: "current",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: "",
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={!curChatEnd}
|
||||
query_intent={query_intent}
|
||||
fetch_source={fetch_source}
|
||||
pick_source={pick_source}
|
||||
deep_read={deep_read}
|
||||
think={think}
|
||||
response={response}
|
||||
loadingStep={loadingStep}
|
||||
/>
|
||||
) : null}
|
||||
{timedoutShow ? (
|
||||
<ChatMessage
|
||||
key={"timedout"}
|
||||
message={{
|
||||
_id: "timedout",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.timedout"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
{errorShow ? (
|
||||
<ChatMessage
|
||||
key={"error"}
|
||||
message={{
|
||||
_id: "error",
|
||||
_source: {
|
||||
type: "assistant",
|
||||
message: t("assistant.chat.error"),
|
||||
question: Question,
|
||||
},
|
||||
}}
|
||||
onResend={handleSendMessage}
|
||||
isTyping={false}
|
||||
/>
|
||||
) : null}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{sessionId && uploadFiles.length > 0 && (
|
||||
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
|
||||
<FileList sessionId={sessionId} getFileUrl={getFileUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessionId && <SessionFile sessionId={sessionId} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,9 +17,6 @@ import {
|
||||
PopoverPanel,
|
||||
} from "@headlessui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import HistoryIcon from "@/icons/History";
|
||||
@@ -31,7 +28,7 @@ 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";
|
||||
interface ChatHeaderProps {
|
||||
onCreateNewChat: () => void;
|
||||
onOpenChatAI: () => void;
|
||||
@@ -58,7 +55,7 @@ export function ChatHeader({
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
|
||||
const { connected, setMessages } = useChatStore();
|
||||
const { setMessages } = useChatStore();
|
||||
|
||||
const [serverList, setServerList] = useState<IServer[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
@@ -67,7 +64,7 @@ export function ChatHeader({
|
||||
const setCurrentService = useConnectStore((state) => state.setCurrentService);
|
||||
|
||||
const fetchServers = useCallback(async (resetSelection: boolean) => {
|
||||
invoke("list_coco_servers")
|
||||
platformAdapter.invokeBackend("list_coco_servers")
|
||||
.then((res: any) => {
|
||||
const enabledServers = (res as IServer[]).filter(
|
||||
(server) => server.enabled !== false
|
||||
@@ -95,27 +92,18 @@ export function ChatHeader({
|
||||
useEffect(() => {
|
||||
fetchServers(true);
|
||||
|
||||
const unlisten = listen("login_or_logout", (event) => {
|
||||
const unlisten = platformAdapter.listenEvent("login_or_logout", (event) => {
|
||||
console.log("Login or Logout:", currentService, event);
|
||||
fetchServers(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cleanup logic if needed
|
||||
disconnect();
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disconnect = async () => {
|
||||
if (!connected) return;
|
||||
try {
|
||||
console.log("disconnect");
|
||||
await invoke("disconnect");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const switchServer = async (server: IServer) => {
|
||||
if (!server) return;
|
||||
@@ -131,8 +119,9 @@ export function ChatHeader({
|
||||
return;
|
||||
}
|
||||
setIsLogin(true);
|
||||
//
|
||||
await disconnect();
|
||||
// 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);
|
||||
@@ -142,7 +131,7 @@ export function ChatHeader({
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await getCurrentWindow().setAlwaysOnTop(newPinned);
|
||||
await platformAdapter.setAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
@@ -151,7 +140,7 @@ export function ChatHeader({
|
||||
};
|
||||
|
||||
const openSettings = async () => {
|
||||
emit("open_settings", "connect");
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
44
src/components/Assistant/ChatSidebar.tsx
Normal file
44
src/components/Assistant/ChatSidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
|
||||
import { Sidebar } from "@/components/Assistant/Sidebar";
|
||||
import type { Chat } from "./types";
|
||||
|
||||
interface ChatSidebarProps {
|
||||
isSidebarOpen: boolean;
|
||||
chats: Chat[];
|
||||
activeChat?: Chat;
|
||||
onNewChat: () => void;
|
||||
onSelectChat: (chat: any) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
fetchChatHistory: () => void;
|
||||
}
|
||||
|
||||
export const ChatSidebar: React.FC<ChatSidebarProps> = ({
|
||||
isSidebarOpen,
|
||||
chats,
|
||||
activeChat,
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
onDeleteChat,
|
||||
fetchChatHistory,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
data-sidebar
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 transform transition-all duration-300 ease-in-out
|
||||
${isSidebarOpen ? "translate-x-0" : "-translate-x-[calc(100%)]"}
|
||||
md:relative md:translate-x-0 bg-gray-100 dark:bg-gray-800
|
||||
border-r border-gray-200 dark:border-gray-700 rounded-tl-xl rounded-bl-xl
|
||||
overflow-hidden`}
|
||||
>
|
||||
<Sidebar
|
||||
chats={chats}
|
||||
activeChat={activeChat}
|
||||
onNewChat={onNewChat}
|
||||
onSelectChat={onSelectChat}
|
||||
onDeleteChat={onDeleteChat}
|
||||
fetchChatHistory={fetchChatHistory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
|
||||
import LoginDark from "@/assets/images/login-dark.svg";
|
||||
import LoginLight from "@/assets/images/login-light.svg";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
const ConnectPrompt = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -13,7 +13,7 @@ const ConnectPrompt = () => {
|
||||
const logo = isDark ? LoginDark : LoginLight;
|
||||
|
||||
const handleConnect = async () => {
|
||||
emit("open_settings", "connect");
|
||||
platformAdapter.emitEvent("open_settings", "connect");
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
112
src/components/Assistant/FileList.tsx
Normal file
112
src/components/Assistant/FileList.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { filesize } from "filesize";
|
||||
import { X } from "lucide-react";
|
||||
import { useAsyncEffect } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
|
||||
interface FileListProps {
|
||||
sessionId: string;
|
||||
getFileUrl: (path: string) => string;
|
||||
}
|
||||
|
||||
const FileList = (props: FileListProps) => {
|
||||
const { sessionId } = props;
|
||||
const { t } = useTranslation();
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setUploadFiles([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async () => {
|
||||
if (uploadFiles.length === 0) return;
|
||||
|
||||
for await (const item of uploadFiles) {
|
||||
const { uploaded, path } = item;
|
||||
|
||||
if (uploaded) continue;
|
||||
|
||||
const attachmentIds = await uploadAttachment({
|
||||
serverId,
|
||||
sessionId,
|
||||
filePaths: [path],
|
||||
});
|
||||
|
||||
if (!attachmentIds) continue;
|
||||
|
||||
Object.assign(item, {
|
||||
uploaded: true,
|
||||
attachmentId: attachmentIds[0],
|
||||
});
|
||||
|
||||
setUploadFiles(uploadFiles);
|
||||
}
|
||||
}, [uploadFiles]);
|
||||
|
||||
const deleteFile = async (id: string, attachmentId: string) => {
|
||||
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
|
||||
|
||||
deleteAttachment({ serverId, id: attachmentId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadFiles.map((file) => {
|
||||
const { id, name, extname, size, uploaded, attachmentId } = file;
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
{attachmentId && (
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
deleteFile(id, attachmentId);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileIcon extname={extname} />
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#999999]">
|
||||
{uploaded ? (
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{t("assistant.fileList.uploading")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
160
src/components/Assistant/SessionFile.tsx
Normal file
160
src/components/Assistant/SessionFile.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
AttachmentHit,
|
||||
deleteAttachment,
|
||||
getAttachment,
|
||||
} from "@/api/attachment";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import clsx from "clsx";
|
||||
import { filesize } from "filesize";
|
||||
import { Files, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Checkbox from "../Common/Checkbox";
|
||||
import FileIcon from "../Common/Icons/FileIcon";
|
||||
|
||||
interface SessionFileProps {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
const SessionFile = (props: SessionFileProps) => {
|
||||
const { sessionId } = props;
|
||||
const { t } = useTranslation();
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [checkList, setCheckList] = useState<string[]>([]);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
return currentService.id;
|
||||
}, [currentService]);
|
||||
|
||||
useEffect(() => {
|
||||
setUploadedFiles([]);
|
||||
|
||||
getUploadedFiles();
|
||||
}, [sessionId]);
|
||||
|
||||
const getUploadedFiles = async () => {
|
||||
const response = await getAttachment({ serverId, sessionId });
|
||||
|
||||
setUploadedFiles(response.hits.hits);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
const result = await deleteAttachment({ serverId, id });
|
||||
|
||||
if (!result) return;
|
||||
|
||||
getUploadedFiles();
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckList(uploadedFiles.map((item) => item._source.id));
|
||||
} else {
|
||||
setCheckList([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (checked: boolean, id: string) => {
|
||||
if (checked) {
|
||||
setCheckList([...checkList, id]);
|
||||
} else {
|
||||
setCheckList(checkList.filter((item) => item !== id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("select-none", {
|
||||
hidden: uploadedFiles.length === 0,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
>
|
||||
<Files className="size-5 text-white" />
|
||||
|
||||
<div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
|
||||
{uploadedFiles.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
|
||||
{
|
||||
hidden: !visible,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<X
|
||||
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
|
||||
{t("assistant.sessionFile.title")}
|
||||
</div>
|
||||
<div className="flex items-center justify-between pr-2">
|
||||
<span className="text-sm text-[#999]">
|
||||
{t("assistant.sessionFile.description")}
|
||||
</span>
|
||||
|
||||
<Checkbox
|
||||
indeterminate
|
||||
checked={checkList.length === uploadedFiles.length}
|
||||
onChange={handleCheckAll}
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6">
|
||||
{uploadedFiles.map((item) => {
|
||||
const { id, name, icon, size } = item._source;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={id}
|
||||
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileIcon extname={icon} />
|
||||
|
||||
<div>
|
||||
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
<div className="text-xs text-[#999]">
|
||||
<span>{icon}</span>
|
||||
<span className="pl-2">
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2
|
||||
className="size-4 text-[#999] cursor-pointer"
|
||||
onClick={() => handleDelete(id)}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={checkList.includes(id)}
|
||||
onChange={(checked) => handleCheck(checked, id)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SessionFile;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageSquare, Plus } from "lucide-react";
|
||||
import { MessageSquare, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import type { Chat } from "./types";
|
||||
|
||||
@@ -10,6 +11,7 @@ interface SidebarProps {
|
||||
onSelectChat: (chat: Chat) => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
className?: string;
|
||||
fetchChatHistory: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -18,19 +20,37 @@ export function Sidebar({
|
||||
onNewChat,
|
||||
onSelectChat,
|
||||
className = "",
|
||||
fetchChatHistory,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between gap-1 p-4">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
className={`flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all border border-[#E6E6E6] dark:border-[#272626] text-gray-700 hover:bg-gray-50/80 active:bg-gray-100/80 dark:text-white dark:hover:bg-gray-600/50 dark:active:bg-gray-500/50`}
|
||||
>
|
||||
<Plus className={`h-4 w-4 text-[#0072FF] dark:text-[#0072FF]`} />
|
||||
{t("assistant.sidebar.newChat")}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
fetchChatHistory();
|
||||
setTimeout(() => setIsRefreshing(false), 1000);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
|
||||
isRefreshing ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 pb-3 space-y-2 custom-scrollbar">
|
||||
{chats.map((chat) => (
|
||||
|
||||
238
src/components/AudioRecording/index.tsx
Normal file
238
src/components/AudioRecording/index.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useKeyPress, useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
import { Check, Loader, Mic, X } from "lucide-react";
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import {
|
||||
checkMicrophonePermission,
|
||||
requestMicrophonePermission,
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
import { useWavesurfer } from "@wavesurfer/react";
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js";
|
||||
import { transcription } from "@/api/transcription";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface AudioRecordingProps {
|
||||
onChange?: (text: string) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
audioDevices: MediaDeviceInfo[];
|
||||
isRecording: boolean;
|
||||
converting: boolean;
|
||||
countdown: number;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: State = {
|
||||
audioDevices: [],
|
||||
isRecording: false,
|
||||
converting: false,
|
||||
countdown: 30,
|
||||
};
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
const AudioRecording: FC<AudioRecordingProps> = (props) => {
|
||||
const { onChange } = props;
|
||||
const state = useReactive({ ...INITIAL_STATE });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const recordRef = useRef<RecordPlugin>();
|
||||
const withVisibility = useAppStore((state) => state.withVisibility);
|
||||
const currentService = useConnectStore((state) => state.currentService);
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const voiceInput = useShortcutsStore((state) => state.voiceInput);
|
||||
|
||||
const { wavesurfer } = useWavesurfer({
|
||||
container: containerRef,
|
||||
height: 20,
|
||||
waveColor: "#0072ff",
|
||||
progressColor: "#999",
|
||||
barWidth: 4,
|
||||
barRadius: 4,
|
||||
barGap: 2,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
getAvailableAudioDevices();
|
||||
|
||||
return resetState;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const record = wavesurfer.registerPlugin(
|
||||
RecordPlugin.create({
|
||||
scrollingWaveform: true,
|
||||
renderRecordedAudio: false,
|
||||
})
|
||||
);
|
||||
|
||||
record.on("record-end", (blob) => {
|
||||
if (!state.converting) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onloadend = async () => {
|
||||
const base64Audio = (reader.result as string).split(",")[1];
|
||||
|
||||
const response = await transcription({
|
||||
serverId: currentService.id,
|
||||
audioType: "mp3",
|
||||
audioContent: base64Audio,
|
||||
});
|
||||
|
||||
if (!response) return;
|
||||
|
||||
onChange?.(response.text);
|
||||
|
||||
resetState();
|
||||
};
|
||||
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
recordRef.current = record;
|
||||
}, [wavesurfer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isRecording) return;
|
||||
|
||||
interval = setInterval(() => {
|
||||
if (state.countdown <= 0) {
|
||||
handleOk();
|
||||
}
|
||||
|
||||
state.countdown--;
|
||||
}, 1000);
|
||||
}, [state.isRecording]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${voiceInput}`, () => {
|
||||
startRecording();
|
||||
});
|
||||
|
||||
const getAvailableAudioDevices = async () => {
|
||||
state.audioDevices = await RecordPlugin.getAvailableAudioDevices();
|
||||
};
|
||||
|
||||
const resetState = (otherState: Partial<State> = {}) => {
|
||||
clearInterval(interval);
|
||||
recordRef.current?.stopRecording();
|
||||
Object.assign(state, {
|
||||
...INITIAL_STATE,
|
||||
...otherState,
|
||||
audioDevices: state.audioDevices,
|
||||
});
|
||||
};
|
||||
|
||||
const checkPermission = async () => {
|
||||
const authorized = await checkMicrophonePermission();
|
||||
|
||||
if (authorized) return;
|
||||
|
||||
requestMicrophonePermission();
|
||||
|
||||
return new Promise(async (resolved) => {
|
||||
const timer = setInterval(async () => {
|
||||
const authorized = await checkMicrophonePermission();
|
||||
|
||||
if (!authorized) return;
|
||||
|
||||
clearInterval(timer);
|
||||
|
||||
resolved(true);
|
||||
}, 500);
|
||||
});
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
await withVisibility(checkPermission);
|
||||
state.isRecording = true;
|
||||
recordRef.current?.startRecording();
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
resetState({ converting: true, countdown: state.countdown });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
"p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
|
||||
{
|
||||
hidden: state.audioDevices.length === 0,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Mic
|
||||
className={clsx("size-4 text-[#999]", {
|
||||
hidden: modifierKeyPressed,
|
||||
})}
|
||||
onClick={startRecording}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
|
||||
{
|
||||
hidden: !modifierKeyPressed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{voiceInput}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
|
||||
{
|
||||
"!translate-x-0": state.isRecording || state.converting,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<button
|
||||
disabled={state.converting}
|
||||
className={clsx(
|
||||
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
|
||||
{
|
||||
"!cursor-not-allowed opacity-50": state.converting,
|
||||
}
|
||||
)}
|
||||
onClick={() => resetState()}
|
||||
>
|
||||
<X className="size-4 text-[#0C0C0C] dark:text-[#999999]" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1 flex-1 h-6 px-2 bg-white dark:bg-black rounded-full transition">
|
||||
<div ref={containerRef} className="flex-1"></div>
|
||||
|
||||
<span className="text-xs text-[#333] dark:text-[#999]">
|
||||
{state.countdown}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={state.converting}
|
||||
className="flex items-center justify-center size-6 text-white bg-[#0072FF] rounded-full transition cursor-pointer"
|
||||
onClick={handleOk}
|
||||
>
|
||||
{state.converting ? (
|
||||
<Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioRecording;
|
||||
@@ -9,7 +9,9 @@ import RehypeHighlight from "rehype-highlight";
|
||||
import mermaid from "mermaid";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
|
||||
import { copyToClipboard, useWindowSize } from "@/utils";
|
||||
import { copyToClipboard,
|
||||
// useWindowSize
|
||||
} from "@/utils";
|
||||
|
||||
import "./markdown.css";
|
||||
import "./highlight.css";
|
||||
@@ -67,9 +69,9 @@ function PreCode(props: { children?: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
// const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
const [htmlCode, setHtmlCode] = useState("");
|
||||
const { height } = useWindowSize();
|
||||
console.log(htmlCode, height);
|
||||
// const [htmlCode, setHtmlCode] = useState("");
|
||||
// const { height } = useWindowSize();
|
||||
// console.log(htmlCode, height);
|
||||
|
||||
const renderArtifacts = useDebouncedCallback(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -77,17 +79,17 @@ function PreCode(props: { children?: any }) {
|
||||
if (mermaidDom) {
|
||||
setMermaidCode((mermaidDom as HTMLElement).innerText);
|
||||
}
|
||||
const htmlDom = ref.current.querySelector("code.language-html");
|
||||
const refText = ref.current.querySelector("code")?.innerText;
|
||||
if (htmlDom) {
|
||||
setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
} else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
setHtmlCode(refText);
|
||||
}
|
||||
// const htmlDom = ref.current.querySelector("code.language-html");
|
||||
// const refText = ref.current.querySelector("code")?.innerText;
|
||||
// if (htmlDom) {
|
||||
// setHtmlCode((htmlDom as HTMLElement).innerText);
|
||||
// } else if (refText?.startsWith("<!DOCTYPE")) {
|
||||
// setHtmlCode(refText);
|
||||
// }
|
||||
}, 600);
|
||||
|
||||
const enableArtifacts = true;
|
||||
console.log(enableArtifacts);
|
||||
// const enableArtifacts = true;
|
||||
// console.log(enableArtifacts);
|
||||
|
||||
//Wrap the paragraph for plain-text
|
||||
useEffect(() => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export const PickSource = ({
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
|
||||
if (loading) {
|
||||
if (!loading) {
|
||||
try {
|
||||
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
|
||||
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
|
||||
@@ -44,7 +44,7 @@ export const PickSource = ({
|
||||
if (allMatches) {
|
||||
for (let i = allMatches.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>/g, "");
|
||||
const jsonString = allMatches[i].replace(/<JSON>|<\/JSON>|<think>|<\/think>/g, "");
|
||||
const data = JSON.parse(jsonString.trim());
|
||||
|
||||
if (
|
||||
|
||||
@@ -42,7 +42,7 @@ export const QueryIntent = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!ChunkData?.message_chunk) return;
|
||||
if (loading) {
|
||||
if (!loading) {
|
||||
const cleanContent = ChunkData.message_chunk.replace(/^"|"$/g, "");
|
||||
const allMatches = cleanContent.match(/<JSON>([\s\S]*?)<\/JSON>/g);
|
||||
if (allMatches) {
|
||||
@@ -108,7 +108,7 @@ export const QueryIntent = ({
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Data?.keyword?.map((keyword, index) => (
|
||||
<span
|
||||
key={index}
|
||||
key={keyword + index}
|
||||
className="text-[#333333] dark:text-[#D8D8D8]"
|
||||
>
|
||||
{keyword}
|
||||
@@ -144,8 +144,8 @@ export const QueryIntent = ({
|
||||
- {t("assistant.message.steps.relatedQuestions")}:
|
||||
</span>
|
||||
<div className="flex-1 flex flex-col text-[#333333] dark:text-[#D8D8D8]">
|
||||
{Data?.query?.map((question) => (
|
||||
<span key={question}>- {question}</span>
|
||||
{Data?.query?.map((question, qIndex) => (
|
||||
<span key={question + qIndex}>- {question}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
33
src/components/ChatMessage/UserMessage.tsx
Normal file
33
src/components/ChatMessage/UserMessage.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { CopyButton } from "@/components/Common/CopyButton";
|
||||
|
||||
interface UserMessageProps {
|
||||
messageContent: string;
|
||||
}
|
||||
|
||||
export const UserMessage = ({ messageContent }: UserMessageProps) => {
|
||||
const [showCopyButton, setShowCopyButton] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-1 items-center"
|
||||
onMouseEnter={() => setShowCopyButton(true)}
|
||||
onMouseLeave={() => setShowCopyButton(false)}
|
||||
>
|
||||
{showCopyButton && <CopyButton textToCopy={messageContent} />}
|
||||
<div
|
||||
className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8] cursor-pointer select-none"
|
||||
onDoubleClick={(e) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}}
|
||||
>
|
||||
{messageContent}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import { Think } from "./Think";
|
||||
import { MessageActions } from "./MessageActions";
|
||||
import Markdown from "./Markdown";
|
||||
import { SuggestionList } from "./SuggestionList";
|
||||
import { UserMessage } from "./UserMessage";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
@@ -55,11 +56,7 @@ export const ChatMessage = memo(function ChatMessage({
|
||||
|
||||
const renderContent = () => {
|
||||
if (!isAssistant) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-white dark:bg-[#202126] rounded-xl border border-black/12 dark:border-black/15 font-normal text-sm text-[#333333] dark:text-[#D8D8D8]">
|
||||
{messageContent}
|
||||
</div>
|
||||
);
|
||||
return <UserMessage messageContent={messageContent} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -235,7 +235,6 @@
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: var(--color-border-default);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.markdown-body input {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
getCurrent as getCurrentDeepLinkUrls,
|
||||
onOpenUrl,
|
||||
} from "@tauri-apps/plugin-deep-link";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
@@ -28,6 +27,17 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import bannerImg from "@/assets/images/coco-cloud-banner.jpeg";
|
||||
import SettingsToggle from "@/components/Settings/SettingsToggle";
|
||||
import Tooltip from "@/components/Common/Tooltip";
|
||||
import {
|
||||
list_coco_servers,
|
||||
add_coco_server,
|
||||
enable_server,
|
||||
disable_server,
|
||||
logout_coco_server,
|
||||
remove_coco_server,
|
||||
refresh_coco_server_info,
|
||||
handle_sso_callback,
|
||||
} from "@/commands";
|
||||
|
||||
export default function Cloud() {
|
||||
const { t } = useTranslation();
|
||||
@@ -65,7 +75,7 @@ export default function Cloud() {
|
||||
}, [JSON.stringify(currentService)]);
|
||||
|
||||
const fetchServers = async (resetSelection: boolean) => {
|
||||
invoke("list_coco_servers")
|
||||
list_coco_servers()
|
||||
.then((res: any) => {
|
||||
if (error) {
|
||||
res = (res || []).map((item: any) => {
|
||||
@@ -97,7 +107,7 @@ export default function Cloud() {
|
||||
});
|
||||
};
|
||||
|
||||
const add_coco_server = (endpointLink: string) => {
|
||||
const addServer = (endpointLink: string) => {
|
||||
if (!endpointLink) {
|
||||
throw new Error("Endpoint is required");
|
||||
}
|
||||
@@ -110,7 +120,7 @@ export default function Cloud() {
|
||||
|
||||
setRefreshLoading(true);
|
||||
|
||||
return invoke("add_coco_server", { endpoint: endpointLink })
|
||||
return add_coco_server(endpointLink)
|
||||
.then((res: any) => {
|
||||
// console.log("add_coco_server", res);
|
||||
fetchServers(false)
|
||||
@@ -125,7 +135,6 @@ export default function Cloud() {
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
// Handle the invoke error
|
||||
console.error("add coco server failed:", err);
|
||||
setError(err);
|
||||
throw err; // Propagate error back up
|
||||
@@ -137,14 +146,14 @@ export default function Cloud() {
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (code: string | null, serverId: string | null) => {
|
||||
if (!code) {
|
||||
if (!code || !serverId) {
|
||||
setError("No authorization code received");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Handling OAuth callback:", { code, serverId });
|
||||
await invoke("handle_sso_callback", {
|
||||
await handle_sso_callback({
|
||||
serverId: serverId, // Make sure 'server_id' is the correct argument
|
||||
requestId: ssoRequestID, // Make sure 'request_id' is the correct argument
|
||||
code: code,
|
||||
@@ -256,7 +265,7 @@ export default function Cloud() {
|
||||
|
||||
const refreshClick = (id: string) => {
|
||||
setRefreshLoading(true);
|
||||
invoke("refresh_coco_server_info", { id })
|
||||
refresh_coco_server_info(id)
|
||||
.then((res: any) => {
|
||||
console.log("refresh_coco_server_info", id, res);
|
||||
fetchServers(false).then((r) => {
|
||||
@@ -282,7 +291,7 @@ export default function Cloud() {
|
||||
function onLogout(id: string) {
|
||||
console.log("onLogout", id);
|
||||
setRefreshLoading(true);
|
||||
invoke("logout_coco_server", { id })
|
||||
logout_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("logout_coco_server", id, JSON.stringify(res));
|
||||
refreshClick(id);
|
||||
@@ -297,8 +306,8 @@ export default function Cloud() {
|
||||
});
|
||||
}
|
||||
|
||||
const remove_coco_server = (id: string) => {
|
||||
invoke("remove_coco_server", { id })
|
||||
const removeServer = (id: string) => {
|
||||
remove_coco_server(id)
|
||||
.then((res: any) => {
|
||||
console.log("remove_coco_server", id, JSON.stringify(res));
|
||||
fetchServers(true).then((r) => {
|
||||
@@ -312,11 +321,14 @@ export default function Cloud() {
|
||||
});
|
||||
};
|
||||
|
||||
const enable_coco_server = useCallback(async (enabled: boolean) => {
|
||||
const enable_coco_server = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
try {
|
||||
const command = enabled ? "enable_server" : "disable_server";
|
||||
|
||||
await invoke(command, { id: currentService?.id });
|
||||
if (enabled) {
|
||||
await enable_server(currentService?.id);
|
||||
} else {
|
||||
await disable_server(currentService?.id);
|
||||
}
|
||||
|
||||
setCurrentService({ ...currentService, enabled });
|
||||
|
||||
@@ -324,7 +336,9 @@ export default function Cloud() {
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
}
|
||||
}, [currentService?.id]);
|
||||
},
|
||||
[currentService?.id]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-50 dark:bg-gray-900">
|
||||
@@ -346,9 +360,11 @@ export default function Cloud() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium">
|
||||
<Tooltip content={currentService?.endpoint}>
|
||||
<div className="flex items-center text-gray-900 dark:text-white font-medium cursor-pointer">
|
||||
{currentService?.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsToggle
|
||||
@@ -385,7 +401,7 @@ export default function Cloud() {
|
||||
{!currentService?.builtin && (
|
||||
<button
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 rounded-[6px] bg-white dark:bg-gray-800 border border-[rgba(228,229,239,1)] dark:border-gray-700"
|
||||
onClick={() => remove_coco_server(currentService?.id)}
|
||||
onClick={() => removeServer(currentService?.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 text-[#ff4747]" />
|
||||
</button>
|
||||
@@ -484,7 +500,7 @@ export default function Cloud() {
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
|
||||
<Connect setIsConnect={setIsConnect} onAddServer={addServer} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { DataSourceItem } from "./DataSourceItem";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import {
|
||||
get_connectors_by_server,
|
||||
get_datasources_by_server,
|
||||
} from "@/commands";
|
||||
|
||||
export function DataSourcesList({ server }: { server: string }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -17,7 +20,7 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
|
||||
function initServerAppData({ server }: { server: string }) {
|
||||
//fetch datasource data
|
||||
invoke("get_connectors_by_server", { id: server })
|
||||
get_connectors_by_server(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_connectors_by_server", res);
|
||||
setConnectorData(res, server);
|
||||
@@ -29,7 +32,7 @@ export function DataSourcesList({ server }: { server: string }) {
|
||||
.finally(() => {});
|
||||
|
||||
//fetch datasource data
|
||||
invoke("get_datasources_by_server", { id: server })
|
||||
get_datasources_by_server(server)
|
||||
.then((res: any) => {
|
||||
// console.log("get_datasources_by_server", res);
|
||||
setDatasourceData(res, server);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useRef, useImperativeHandle, forwardRef } from "react";
|
||||
|
||||
interface AutoResizeTextareaProps {
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
// Forward ref to allow parent to interact with this component
|
||||
const AutoResizeTextarea = forwardRef<
|
||||
{ reset: () => void; focus: () => void },
|
||||
AutoResizeTextareaProps
|
||||
>(({ input, setInput, handleKeyDown }, ref) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Expose methods to the parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
reset: () => {
|
||||
setInput("");
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="text-xs flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
|
||||
placeholder="Ask whatever you want ..."
|
||||
aria-label="Ask whatever you want ..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown?.(e)}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: "none", // Prevent manual resize
|
||||
overflow: "auto", // Enable scrollbars when needed
|
||||
maxHeight: "4.5rem", // Limit height to 3 rows (3 * 1.5 line-height)
|
||||
lineHeight: "1.5rem", // Line height to match row height
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default AutoResizeTextarea;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { Bot, Search } from "lucide-react";
|
||||
|
||||
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
|
||||
interface ChatSwitchProps {
|
||||
isChatMode: boolean;
|
||||
@@ -9,19 +9,26 @@ interface ChatSwitchProps {
|
||||
}
|
||||
|
||||
const ChatSwitch: React.FC<ChatSwitchProps> = ({ isChatMode, onChange }) => {
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modeSwitch = useShortcutsStore((state) => {
|
||||
return state.modeSwitch;
|
||||
});
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
onChange?.(!isChatMode);
|
||||
}, [onChange, isChatMode]);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (isMetaOrCtrlKey(event) && event.key === "t") {
|
||||
if (modifierKeyPressed && event.key === modeSwitch.toLowerCase()) {
|
||||
event.preventDefault();
|
||||
// console.log("Switch mode triggered");
|
||||
handleToggle();
|
||||
}
|
||||
},
|
||||
[handleToggle]
|
||||
[handleToggle, modifierKeyPressed, modeSwitch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
34
src/components/Common/Checkbox/index.tsx
Normal file
34
src/components/Common/Checkbox/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
CheckboxProps as HeadlessCheckboxProps,
|
||||
Checkbox as HeadlessCheckbox,
|
||||
} from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
|
||||
interface CheckboxProps extends HeadlessCheckboxProps {
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
const Checkbox = (props: CheckboxProps) => {
|
||||
const { indeterminate, className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<HeadlessCheckbox
|
||||
{...rest}
|
||||
className={clsx(
|
||||
"group size-4 rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{indeterminate && (
|
||||
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
|
||||
<div className="size-2 bg-[#2F54EB]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckIcon className="hidden size-[14px] text-white group-data-[checked]:block" />
|
||||
</HeadlessCheckbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Checkbox;
|
||||
36
src/components/Common/CopyButton/index.tsx
Normal file
36
src/components/Common/CopyButton/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
interface CopyButtonProps {
|
||||
textToCopy: string;
|
||||
}
|
||||
|
||||
export const CopyButton = ({ textToCopy }: CopyButtonProps) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopied(true);
|
||||
const timerID = setTimeout(() => {
|
||||
setCopied(false);
|
||||
clearTimeout(timerID);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("copy error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`p-1 bg-gray-200 dark:bg-gray-700 rounded`}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-[#38C200] dark:text-[#38C200]" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4 text-gray-600 dark:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
|
||||
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
|
||||
interface DropdownListProps {
|
||||
selected: (item: any) => void;
|
||||
suggests: any[];
|
||||
isSearchComplete: boolean;
|
||||
}
|
||||
|
||||
function DropdownList({ selected, suggests }: DropdownListProps) {
|
||||
const [selectedItem, setSelectedItem] = useState<number | null>(null);
|
||||
const [showIndex, setShowIndex] = useState<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
const handleOpenURL = async (url: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await open(url);
|
||||
// console.log("URL opened in default browser");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// console.log(
|
||||
// "handleKeyDown",
|
||||
// e.key,
|
||||
// showIndex,
|
||||
// e.key >= "0" && e.key <= "9" && showIndex
|
||||
// );
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) =>
|
||||
prev === null || prev === 0 ? suggests.length - 1 : prev - 1
|
||||
);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedItem((prev) =>
|
||||
prev === null || prev === suggests.length - 1 ? 0 : prev + 1
|
||||
);
|
||||
} else if (e.key === metaOrCtrlKey()) {
|
||||
e.preventDefault();
|
||||
setShowIndex(true);
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
// console.log("Enter key pressed", selectedItem);
|
||||
const item = suggests[selectedItem];
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key >= "0" && e.key <= "9" && showIndex) {
|
||||
// console.log(`number ${e.key}`);
|
||||
const item = suggests[parseInt(e.key, 10)];
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
// console.log("handleKeyUp", e.key);
|
||||
if (!suggests.length) return;
|
||||
|
||||
if (!isMetaOrCtrlKey(e)) {
|
||||
setShowIndex(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [showIndex, selectedItem, suggests]);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (suggests.length > 0) {
|
||||
// setSelectedItem(0);
|
||||
// }
|
||||
// }, [JSON.stringify(suggests)]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedItem !== null && itemRefs.current[selectedItem]) {
|
||||
itemRefs.current[selectedItem]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}, [selectedItem]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-tauri-drag-region
|
||||
className="max-h-[458px] w-full p-2 flex flex-col rounded-xl overflow-y-auto overflow-hidden custom-scrollbar focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="p-2 text-xs text-[#999] dark:text-[#666]">Results</div>
|
||||
{suggests?.map((item, index) => {
|
||||
const isSelected = selectedItem === index;
|
||||
return (
|
||||
<div
|
||||
key={item._id}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
onMouseEnter={() => setSelectedItem(index)}
|
||||
onClick={() => {
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}}
|
||||
className={`w-full px-2 py-2.5 text-sm flex items-center justify-between rounded-lg transition-colors ${
|
||||
isSelected
|
||||
? "bg-[rgba(0,0,0,0.1)] dark:bg-[rgba(255,255,255,0.1)] hover:bg-[rgba(0,0,0,0.1)] dark:hover:bg-[rgba(255,255,255,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-2 items-center">
|
||||
<img className="w-5 h-5" src={item?.icon} alt="icon" />
|
||||
<span className="text-[#333] dark:text-[#d8d8d8] truncate w-80 text-left">
|
||||
{item?.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center relative">
|
||||
<span className="text-sm text-[#666] dark:text-[#666] truncate w-52 text-right">
|
||||
{item?.source}
|
||||
</span>
|
||||
{showIndex && index < 10 ? (
|
||||
<div
|
||||
className={`absolute right-0 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] shadow-[-6px_0px_6px_2px_#e6e6e6] dark:shadow-[-6px_0px_6px_2px_#000] rounded-md`}
|
||||
>
|
||||
{index}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DropdownList;
|
||||
47
src/components/Common/ErrorBoundary.tsx
Normal file
47
src/components/Common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Component, ErrorInfo, ReactNode } from "react";
|
||||
|
||||
import { ErrorDisplay } from "@/components/Common/ErrorDisplay";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
class ErrorBoundaryClass extends Component<
|
||||
Props & { onError: (error: Error, errorInfo: ErrorInfo) => void }
|
||||
> {
|
||||
state = { hasError: false, error: null as Error | null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<ErrorDisplay errorMessage={this.state.error?.message} />
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const ErrorBoundary = ({ children, fallback }: Props) => {
|
||||
const handleError = (error: Error, errorInfo: ErrorInfo) => {
|
||||
console.error("Uncaught error:", error, errorInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<ErrorBoundaryClass onError={handleError} fallback={fallback}>
|
||||
{children}
|
||||
</ErrorBoundaryClass>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
32
src/components/Common/ErrorDisplay.tsx
Normal file
32
src/components/Common/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import errorImg from "@/assets/error_page.png";
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ errorMessage }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen bg-white shadow-[0px_16px_32px_0px_rgba(0,0,0,0.4)] rounded-xl border-[2px] border-[#E6E6E6] m-auto">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<img
|
||||
src={errorImg}
|
||||
alt="error-page"
|
||||
className="w-[221px] h-[154px] mb-8 mt-[72px]"
|
||||
/>
|
||||
<div className="w-[380px] h-[46px] px-5 font-normal text-base text-[rgba(0,0,0,0.85)] leading-[25px] text-center mb-4">
|
||||
{t('error.message')}
|
||||
</div>
|
||||
{errorMessage && (
|
||||
<div className="w-[380px] h-[45px] font-normal text-[10px] text-[rgba(135,135,135,0.85)] leading-[16px] text-center">
|
||||
<i>{errorMessage}</i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
// Settings,
|
||||
// LogOut,
|
||||
Command,
|
||||
// User,
|
||||
// Home,
|
||||
// ChevronUp,
|
||||
ArrowDown01,
|
||||
AppWindowMac,
|
||||
// ArrowDownUp,
|
||||
CornerDownLeft,
|
||||
} from "lucide-react";
|
||||
// import { Menu, MenuButton, MenuItems, MenuItem } from "@headlessui/react";
|
||||
// import { Link } from "react-router-dom";
|
||||
// import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
interface FooterProps {
|
||||
isChat: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const Footer = ({ name }: FooterProps) => {
|
||||
// async function openWebviewWindowSettings() {
|
||||
// const webview = new WebviewWindow("settings", {
|
||||
// title: "Coco Settings",
|
||||
// dragDropEnabled: true,
|
||||
// center: true,
|
||||
// width: 900,
|
||||
// height: 700,
|
||||
// alwaysOnTop: true,
|
||||
// skipTaskbar: true,
|
||||
// decorations: true,
|
||||
// closable: true,
|
||||
// url: "/ui/settings",
|
||||
// });
|
||||
// webview.once("tauri://created", function () {
|
||||
// console.log("webview created");
|
||||
// });
|
||||
// webview.once("tauri://error", function (e) {
|
||||
// console.log("error creating webview", e);
|
||||
// });
|
||||
// }
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="px-4 z-999 mx-[1px] h-10 absolute bottom-0 left-0 right-0 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between rounded-xl rounded-t-none overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{
|
||||
name ? (
|
||||
<div className="flex gap-2 items-center text-[#666] text-xs">
|
||||
<AppWindowMac className="w-5 h-5" /> {name}
|
||||
</div>
|
||||
) : null
|
||||
// <Menu as="div" className="relative">
|
||||
// <MenuButton className="h-7 flex items-center space-x-2 px-3 py-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||
// <Command className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
// <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
// Coco
|
||||
// </span>
|
||||
// <ChevronUp className="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
// </MenuButton>
|
||||
|
||||
// <MenuItems className="absolute bottom-full mb-2 left-0 w-64 rounded-lg bg-white dark:bg-gray-700 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
// <div className="p-1">
|
||||
// <MenuItem>
|
||||
// {({ active }) => (
|
||||
// <button
|
||||
// className={`${
|
||||
// active
|
||||
// ? "bg-gray-100 dark:bg-gray-700"
|
||||
// : "text-gray-900 dark:text-gray-100"
|
||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
||||
// >
|
||||
// <Home className="w-4 h-4 mr-2" />
|
||||
// <Link to={`/`}>Home</Link>
|
||||
// </button>
|
||||
// )}
|
||||
// </MenuItem>
|
||||
// {/* <MenuItem>
|
||||
// {({ active }) => (
|
||||
// <button
|
||||
// className={`${
|
||||
// active
|
||||
// ? "bg-gray-100 dark:bg-gray-700"
|
||||
// : "text-gray-900 dark:text-gray-100"
|
||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
||||
// >
|
||||
// <User className="w-4 h-4 mr-2" />
|
||||
// Profile
|
||||
// </button>
|
||||
// )}
|
||||
// </MenuItem> */}
|
||||
// <MenuItem>
|
||||
// {({ active }) => (
|
||||
// <button
|
||||
// className={`${
|
||||
// active
|
||||
// ? "bg-gray-100 dark:bg-gray-700"
|
||||
// : "text-gray-900 dark:text-gray-100"
|
||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
||||
// onClick={openWebviewWindowSettings}
|
||||
// >
|
||||
// <Settings className="w-4 h-4 mr-2" />
|
||||
// Settings
|
||||
// </button>
|
||||
// )}
|
||||
// </MenuItem>
|
||||
// {/* <div className="h-px bg-gray-200 dark:bg-gray-700 my-1" />
|
||||
// <MenuItem>
|
||||
// {({ active }) => (
|
||||
// <button
|
||||
// className={`${
|
||||
// active
|
||||
// ? "bg-gray-100 dark:bg-gray-700"
|
||||
// : "text-gray-900 dark:text-gray-100"
|
||||
// } group flex w-full items-center rounded-md px-3 py-2 text-sm`}
|
||||
// >
|
||||
// <LogOut className="w-4 h-4 mr-2" />
|
||||
// Sign Out
|
||||
// </button>
|
||||
// )}
|
||||
// </MenuItem> */}
|
||||
// </div>
|
||||
// </MenuItems>
|
||||
// </Menu>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-sm">
|
||||
<span className="mr-1.5 ">Quick open</span>
|
||||
<Command className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
||||
<ArrowDown01 className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
||||
</div>
|
||||
<div className="flex items-center text-[#666] dark:text-[#666] text-sm">
|
||||
<span className="mr-1.5 ">Open</span>
|
||||
<CornerDownLeft className="w-5 h-5 p-1 border rounded-[6px] dark:text-[#666] dark:border-[rgba(255,255,255,0.15)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
src/components/Common/Icons/FileIcon/AudioIcon.tsx
Normal file
21
src/components/Common/Icons/FileIcon/AudioIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
const AudioIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>audio</title>
|
||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M10.7315824,7.11216117 C10.7428131,7.15148751 10.7485063,7.19218979 10.7485063,7.23309113 L10.7485063,8.07742614 C10.7484199,8.27364959 10.6183424,8.44607275 10.4296853,8.50003683 L8.32984514,9.09986306 L8.32984514,11.7071803 C8.32986605,12.5367078 7.67249692,13.217028 6.84345686,13.2454634 L6.79068592,13.2463395 C6.12766108,13.2463395 5.53916361,12.8217001 5.33010655,12.1924966 C5.1210495,11.563293 5.33842118,10.8709227 5.86959669,10.4741173 C6.40077221,10.0773119 7.12636292,10.0652587 7.67042486,10.4442027 L7.67020842,7.74937024 L7.68449368,7.74937024 C7.72405122,7.59919041 7.83988806,7.48101083 7.98924584,7.4384546 L10.1880418,6.81004755 C10.42156,6.74340323 10.6648954,6.87865515 10.7315824,7.11216117 Z M9.60714286,1.31785714 L12.9678571,4.67857143 L9.60714286,4.67857143 L9.60714286,1.31785714 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default AudioIcon;
|
||||
21
src/components/Common/Icons/FileIcon/VideoIcon.tsx
Normal file
21
src/components/Common/Icons/FileIcon/VideoIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
const VideoIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 16 16"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>video</title>
|
||||
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M14.1178571,4.0125 C14.225,4.11964286 14.2857143,4.26428571 14.2857143,4.41607143 L14.2857143,15.4285714 C14.2857143,15.7446429 14.0303571,16 13.7142857,16 L2.28571429,16 C1.96964286,16 1.71428571,15.7446429 1.71428571,15.4285714 L1.71428571,0.571428571 C1.71428571,0.255357143 1.96964286,0 2.28571429,0 L9.86964286,0 C10.0214286,0 10.1678571,0.0607142857 10.275,0.167857143 L14.1178571,4.0125 Z M12.9678571,4.67857143 L9.60714286,1.31785714 L9.60714286,4.67857143 L12.9678571,4.67857143 Z M10.5379461,10.3101106 L6.68957555,13.0059749 C6.59910784,13.0693494 6.47439406,13.0473861 6.41101953,12.9569184 C6.3874624,12.9232903 6.37482581,12.8832269 6.37482581,12.8421686 L6.37482581,7.45043999 C6.37482581,7.33998304 6.46436886,7.25043999 6.57482581,7.25043999 C6.61588409,7.25043999 6.65594753,7.26307658 6.68957555,7.28663371 L10.5379461,9.98249803 C10.6284138,10.0458726 10.6503772,10.1705863 10.5870027,10.2610541 C10.5736331,10.2801392 10.5570312,10.2967411 10.5379461,10.3101106 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoIcon;
|
||||
154
src/components/Common/Icons/FileIcon/index.tsx
Normal file
154
src/components/Common/Icons/FileIcon/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
FileExcelFilled,
|
||||
FileImageFilled,
|
||||
FileMarkdownFilled,
|
||||
FilePdfFilled,
|
||||
FilePptFilled,
|
||||
FileTextFilled,
|
||||
FileWordFilled,
|
||||
FileZipFilled,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
import AudioIcon from "./AudioIcon";
|
||||
import VideoIcon from "./VideoIcon";
|
||||
import { FC, useMemo } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface FileIconProps {
|
||||
extname: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const FileIcon: FC<FileIconProps> = (props) => {
|
||||
const { extname, className } = props;
|
||||
|
||||
const presetFileIcons = [
|
||||
{
|
||||
icon: <FileExcelFilled />,
|
||||
color: "#22b35e",
|
||||
extnames: ["xlsx", "xls", "csv", "xlsm", "xltx", "xltm", "xlsb"],
|
||||
},
|
||||
{
|
||||
icon: <FileImageFilled />,
|
||||
color: "#13c2c2",
|
||||
extnames: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"bmp",
|
||||
"webp",
|
||||
"svg",
|
||||
"ico",
|
||||
"tiff",
|
||||
"raw",
|
||||
"heic",
|
||||
"psd",
|
||||
"ai",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <FileMarkdownFilled />,
|
||||
color: "#722ed1",
|
||||
extnames: ["md", "mdx", "markdown", "mdown", "mkd", "mkdn"],
|
||||
},
|
||||
{
|
||||
icon: <FilePdfFilled />,
|
||||
color: "#ff4d4f",
|
||||
extnames: ["pdf", "xps", "oxps"],
|
||||
},
|
||||
{
|
||||
icon: <FilePptFilled />,
|
||||
color: "#d04423",
|
||||
extnames: [
|
||||
"ppt",
|
||||
"pptx",
|
||||
"pps",
|
||||
"ppsx",
|
||||
"pot",
|
||||
"potx",
|
||||
"pptm",
|
||||
"potm",
|
||||
"ppsm",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <FileWordFilled />,
|
||||
color: "#1677ff",
|
||||
extnames: ["doc", "docx", "dot", "dotx", "docm", "dotm", "rtf", "odt"],
|
||||
},
|
||||
{
|
||||
icon: <FileZipFilled />,
|
||||
color: "#fab714",
|
||||
extnames: [
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"tgz",
|
||||
"iso",
|
||||
"dmg",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <VideoIcon />,
|
||||
color: "#7b61ff",
|
||||
extnames: [
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
"3gp",
|
||||
"rmvb",
|
||||
"ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <AudioIcon />,
|
||||
color: "#eb2f96",
|
||||
extnames: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"flac",
|
||||
"ape",
|
||||
"aac",
|
||||
"ogg",
|
||||
"wma",
|
||||
"m4a",
|
||||
"opus",
|
||||
"ac3",
|
||||
"mid",
|
||||
"midi",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const [icon, iconColor] = useMemo(() => {
|
||||
for (const item of presetFileIcons) {
|
||||
const { icon, color, extnames } = item;
|
||||
|
||||
if (extnames.includes(extname)) {
|
||||
return [icon, color];
|
||||
}
|
||||
}
|
||||
|
||||
return [<FileTextFilled key="defaultIcon" />, "#8c8c8c"];
|
||||
}, [extname]);
|
||||
|
||||
return (
|
||||
<div className={clsx("text-3xl", className)} style={{ color: iconColor }}>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileIcon;
|
||||
@@ -32,7 +32,11 @@ function ItemIcon({
|
||||
);
|
||||
}
|
||||
|
||||
const selectedIcon = icons[item?.icon];
|
||||
let selectedIcon = icons[item?.icon];
|
||||
if (!selectedIcon) {
|
||||
selectedIcon=item?.icon
|
||||
}
|
||||
|
||||
if (!selectedIcon) {
|
||||
return (
|
||||
<IconWrapper className={className} onClick={onClick}>
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import { Library, Send, Plus, AudioLines, Image } from "lucide-react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import ChatSwitch from "@/components/Common/ChatSwitch";
|
||||
import AutoResizeTextarea from "./AutoResizeTextarea";
|
||||
import { useChatStore } from "../../stores/chatStore";
|
||||
import StopIcon from "../../icons/Stop";
|
||||
import { useAppStore } from "../../stores/appStore";
|
||||
import { metaOrCtrlKey, isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
disabled: boolean;
|
||||
disabledChange: () => void;
|
||||
changeMode: (isChatMode: boolean) => void;
|
||||
isChatMode: boolean;
|
||||
inputValue: string;
|
||||
changeInput: (val: string) => void;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
onSend,
|
||||
disabled,
|
||||
changeMode,
|
||||
isChatMode,
|
||||
inputValue,
|
||||
changeInput,
|
||||
disabledChange,
|
||||
}: ChatInputProps) {
|
||||
const showTooltip = useAppStore((state) => state.showTooltip);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<{ reset: () => void; focus: () => void }>(null);
|
||||
|
||||
const { curChatEnd } = useChatStore();
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
|
||||
const handleToggleFocus = useCallback(() => {
|
||||
if (isChatMode) {
|
||||
textareaRef.current?.focus();
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isChatMode, textareaRef, inputRef]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (trimmedValue && !disabled) {
|
||||
onSend(trimmedValue);
|
||||
}
|
||||
}, [inputValue, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === metaOrCtrlKey()) {
|
||||
setIsCommandPressed(true);
|
||||
}
|
||||
|
||||
if (isMetaOrCtrlKey(e)) {
|
||||
switch (e.code) {
|
||||
case "KeyI":
|
||||
handleToggleFocus();
|
||||
break;
|
||||
case "KeyM":
|
||||
console.log("KeyM");
|
||||
break;
|
||||
case "Enter":
|
||||
isChatMode && handleSubmit();
|
||||
break;
|
||||
case "KeyO":
|
||||
console.log("KeyO");
|
||||
break;
|
||||
case "KeyU":
|
||||
console.log("KeyU");
|
||||
break;
|
||||
case "KeyN":
|
||||
console.log("KeyN");
|
||||
break;
|
||||
case "KeyG":
|
||||
console.log("KeyG");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleToggleFocus, isChatMode, handleSubmit]
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === metaOrCtrlKey()) {
|
||||
setIsCommandPressed(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [handleKeyDown, handleKeyUp]);
|
||||
|
||||
useEffect(() => {
|
||||
const setupListener = async () => {
|
||||
const unlisten = await listen("tauri://focus", () => {
|
||||
// console.log("Window focused!");
|
||||
if (isChatMode) {
|
||||
textareaRef.current?.focus();
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
setupListener().then((unlistener) => {
|
||||
unlisten = unlistener;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, [isChatMode]);
|
||||
|
||||
const openChatAI = async () => {
|
||||
console.log("Chat AI opened.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl overflow-hidden relative">
|
||||
<div className="rounded-xl">
|
||||
<div className="p-[13px] flex items-center dark:text-[#D8D8D8] bg-white dark:bg-[#202126] rounded-xl transition-all relative">
|
||||
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
|
||||
{isChatMode ? (
|
||||
<AutoResizeTextarea
|
||||
ref={textareaRef}
|
||||
input={inputValue}
|
||||
setInput={changeInput}
|
||||
handleKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck="false"
|
||||
className="text-xs leading-6 font-normal flex-1 outline-none min-w-[200px] text-[#333] dark:text-[#d8d8d8] placeholder-text-xs placeholder-[#999] dark:placeholder-gray-500 bg-transparent"
|
||||
placeholder="Search whatever you want ..."
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
onSend(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + i
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* {isChatMode ? (
|
||||
<button
|
||||
className="p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="w-4 h-4 text-[#999] dark:text-[#999]" />
|
||||
</button>
|
||||
) : null} */}
|
||||
|
||||
{isChatMode && curChatEnd ? (
|
||||
<button
|
||||
className={`ml-1 p-1 ${
|
||||
inputValue
|
||||
? "bg-[#0072FF]"
|
||||
: "bg-[#E4E5F0] dark:bg-[rgb(84,84,84)]"
|
||||
} rounded-full transition-colors`}
|
||||
type="submit"
|
||||
onClick={() => onSend(inputValue.trim())}
|
||||
>
|
||||
<Send className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
) : null}
|
||||
{isChatMode && !curChatEnd ? (
|
||||
<button
|
||||
className={`ml-1 px-1 bg-[#0072FF] rounded-full transition-colors`}
|
||||
type="submit"
|
||||
onClick={() => disabledChange()}
|
||||
>
|
||||
<StopIcon
|
||||
size={16}
|
||||
className="w-4 h-4 text-white"
|
||||
aria-label="Stop message"
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{showTooltip && isChatMode && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute right-16 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + m
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showTooltip && isChatMode && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute right-1 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + ↩︎
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="flex justify-between items-center p-2 rounded-xl"
|
||||
>
|
||||
{isChatMode ? (
|
||||
<div className="flex gap-1 text-xs text-[#333] dark:text-[#d8d8d8]">
|
||||
<button
|
||||
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
|
||||
onClick={openChatAI}
|
||||
>
|
||||
<Library className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
|
||||
Coco
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute left-0 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + o
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
|
||||
<Plus className="w-4 h-4 mr-1 text-[#000] dark:text-[#d8d8d8]" />
|
||||
Upload
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute left-1 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + u
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-28 flex gap-1 relative">
|
||||
<button
|
||||
className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors relative"
|
||||
onClick={openChatAI}
|
||||
>
|
||||
<AudioLines className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
|
||||
</button>
|
||||
<button className="inline-flex items-center p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-color relative">
|
||||
<Image className="w-4 h-4 text-[#000] dark:text-[#d8d8d8]" />
|
||||
</button>
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute left-0 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + n
|
||||
</div>
|
||||
) : null}
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute left-14 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + g
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-24 flex justify-end items-center">
|
||||
{showTooltip && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute left-0 z-10 bg-black bg-opacity-70 text-white font-bold px-2.5 py-1 rounded-md text-xs transition-opacity duration-200`}
|
||||
>
|
||||
⌘ + t
|
||||
</div>
|
||||
) : null}
|
||||
<ChatSwitch
|
||||
isChatMode={isChatMode}
|
||||
onChange={(value) => {
|
||||
value && disabledChange();
|
||||
changeMode(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { isTauri, invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { LogicalSize } from "@tauri-apps/api/dpi";
|
||||
|
||||
import DropdownList from "./DropdownList";
|
||||
import { Footer } from "./Footer";
|
||||
import { SearchResults } from "../Search/SearchResults";
|
||||
import { useAppStore } from '@/stores/appStore';
|
||||
interface SearchProps {
|
||||
changeInput: (val: string) => void;
|
||||
isTransitioned: boolean;
|
||||
isChatMode: boolean;
|
||||
input: string;
|
||||
}
|
||||
|
||||
function Search({ isTransitioned, isChatMode, input }: SearchProps) {
|
||||
const initializeListeners = useAppStore(state => state.initializeListeners);
|
||||
useEffect(() => {
|
||||
initializeListeners();
|
||||
}, []);
|
||||
|
||||
const [suggests, setSuggests] = useState<any[]>([]);
|
||||
const [isSearchComplete, setIsSearchComplete] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<any>();
|
||||
|
||||
const mainWindowRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
const element = mainWindowRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(async (entries) => {
|
||||
for (let entry of entries) {
|
||||
let newHeight = entry.contentRect.height;
|
||||
console.log("Height updated:", newHeight);
|
||||
newHeight = newHeight + 90 + (newHeight === 0 ? 0 : 46);
|
||||
await getCurrentWebviewWindow()?.setSize(
|
||||
new LogicalSize(680, newHeight)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(element);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [suggests]);
|
||||
|
||||
const getSuggest = async () => {
|
||||
if (!input) return
|
||||
try {
|
||||
const response: any = await invoke("query_coco_fusion", {
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: input },
|
||||
});
|
||||
console.log("_suggest", input, response);
|
||||
const data = response.data?.hits?.hits || [];
|
||||
setSuggests(data);
|
||||
//
|
||||
// const list = [];
|
||||
// for (let i = 0; i < input.length; i++) {
|
||||
// list.push({
|
||||
// _source: { url: `https://www.google.com/search?q=${i}` },
|
||||
// });
|
||||
// }
|
||||
// setSuggests(list);
|
||||
//
|
||||
setIsSearchComplete(true);
|
||||
} catch (error) {
|
||||
console.error("query_coco_fusion:", error);
|
||||
}
|
||||
};
|
||||
|
||||
function debounce(fn: Function, delay: number) {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedSearch = useCallback(debounce(getSuggest, 300), [input]);
|
||||
|
||||
useEffect(() => {
|
||||
!isChatMode && debouncedSearch();
|
||||
if (!input) setSuggests([]);
|
||||
}, [input]);
|
||||
|
||||
if (isChatMode || suggests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl overflow-hidden bg-search_bg_light dark:bg-search_bg_dark bg-cover border border-[#E6E6E6] dark:border-[#272626] absolute w-full transition-opacity ${
|
||||
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
} top-[96px]`}
|
||||
style={{
|
||||
backgroundPosition: "-1px 0",
|
||||
backgroundSize: "101% 100%",
|
||||
}}
|
||||
>
|
||||
{!isChatMode ? (
|
||||
<div
|
||||
ref={mainWindowRef}
|
||||
className={`max-h-[498px] pb-10 w-full relative`}
|
||||
>
|
||||
{/* Search Results Panel */}
|
||||
{suggests.length > 0 && !selectedItem ? (
|
||||
<DropdownList
|
||||
suggests={suggests}
|
||||
isSearchComplete={isSearchComplete}
|
||||
selected={(item) => setSelectedItem(item)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{selectedItem ? <SearchResults input={input} isChatMode={isChatMode} /> : null}
|
||||
|
||||
{suggests.length > 0 || selectedItem ? (
|
||||
<Footer isChat={false} name={selectedItem?.source} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Search;
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const TransitionComponent = () => {
|
||||
const [isTransitioned, setIsTransitioned] = useState(false);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsTransitioned(!isTransitioned);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="w-[680px] h-[596px] mx-auto overflow-hidden relative"
|
||||
>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-red-500 text-white flex items-center justify-center transition-all duration-500 ${
|
||||
isTransitioned ? "top-[506px] h-[90px]" : "top-0 h-[90px]"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-black rounded"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-green-500 transition-opacity duration-500 ${
|
||||
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
} bottom-0 h-[calc(100vh-90px)]`}
|
||||
></div>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`shadow-window-custom border border-[#E6E6E6] dark:border-[#272626] absolute w-full bg-yellow-500 transition-all duration-500 ${
|
||||
isTransitioned
|
||||
? "top-0 opacity-100 pointer-events-auto"
|
||||
: "-top-[506px] opacity-0 pointer-events-none"
|
||||
} h-[calc(100vh-90px)]`}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransitionComponent;
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface SearchItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SearchCategory {
|
||||
id: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
items: SearchItem[];
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
useClickAway,
|
||||
useCreation,
|
||||
@@ -15,11 +10,20 @@ import { Link, SquareArrowOutUpRight } from "lucide-react";
|
||||
import { cloneElement, useEffect, useRef } 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";
|
||||
|
||||
interface State {
|
||||
activeMenuIndex: number;
|
||||
}
|
||||
|
||||
const ContextMenu = () => {
|
||||
interface ContextMenuProps {
|
||||
hideCoco: () => Promise<void>;
|
||||
}
|
||||
|
||||
const ContextMenu = ({ hideCoco }: ContextMenuProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -47,6 +51,10 @@ const ContextMenu = () => {
|
||||
shortcut: "enter",
|
||||
clickEvent: () => {
|
||||
OpenURLWithBrowser(selectedSearchContent?.url);
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
|
||||
hideCoco();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -56,6 +64,8 @@ const ContextMenu = () => {
|
||||
shortcut: isMac ? "meta.l" : "ctrl.l",
|
||||
clickEvent: () => {
|
||||
copyToClipboard(selectedSearchContent?.url);
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -108,7 +118,7 @@ const ContextMenu = () => {
|
||||
|
||||
const item = menus.find((item) => item.shortcut === key);
|
||||
|
||||
handleClick(item?.clickEvent);
|
||||
item?.clickEvent();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -118,14 +128,6 @@ const ContextMenu = () => {
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
|
||||
const handleClick = (clickEvent?: () => void) => {
|
||||
clickEvent?.();
|
||||
|
||||
setVisibleContextMenu(false);
|
||||
|
||||
invoke("hide_coco");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleContextMenu && (
|
||||
@@ -165,7 +167,7 @@ const ContextMenu = () => {
|
||||
onMouseEnter={() => {
|
||||
state.activeMenuIndex = index;
|
||||
}}
|
||||
onClick={() => handleClick(clickEvent)}
|
||||
onClick={clickEvent}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-black/80 dark:text-white/80">
|
||||
{cloneElement(icon, { className: "size-4" })}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useInfiniteScroll } from "ahooks";
|
||||
import { isTauri, invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FixedSizeList } from "react-window";
|
||||
|
||||
@@ -10,6 +8,7 @@ import { SearchHeader } from "./SearchHeader";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import SearchListItem from "./SearchListItem";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
|
||||
interface DocumentListProps {
|
||||
onSelectDocument: (id: string) => void;
|
||||
@@ -19,6 +18,11 @@ interface DocumentListProps {
|
||||
selectedId?: string;
|
||||
viewMode: "detail" | "list";
|
||||
setViewMode: (mode: "detail" | "list") => void;
|
||||
queryDocuments: (
|
||||
from: number,
|
||||
size: number,
|
||||
queryStrings: any
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -30,6 +34,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
isChatMode,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
queryDocuments,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
@@ -58,11 +63,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const response: any = await invoke("query_coco_fusion", {
|
||||
from: from,
|
||||
size: PAGE_SIZE,
|
||||
queryStrings,
|
||||
});
|
||||
const response = await queryDocuments(from, PAGE_SIZE, queryStrings);
|
||||
const list = response?.hits || [];
|
||||
const total = response?.total_hits || 0;
|
||||
|
||||
@@ -111,18 +112,6 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
setIsKeyboardMode(false);
|
||||
}, [isChatMode, input]);
|
||||
|
||||
const handleOpenURL = async (url: string) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
if (isTauri()) {
|
||||
await open(url);
|
||||
// console.log("URL opened in default browser");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (!data?.list?.length) return;
|
||||
@@ -158,7 +147,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
if (e.key === "Enter" && selectedItem !== null) {
|
||||
const item = data?.list?.[selectedItem];
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -211,7 +200,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
onMouseEnter={() => onMouseEnter(index, item)}
|
||||
onItemClick={() => {
|
||||
if (item?.url) {
|
||||
handleOpenURL(item?.url);
|
||||
OpenURLWithBrowser(item?.url);
|
||||
}
|
||||
}}
|
||||
showListRight={viewMode === "list"}
|
||||
@@ -219,7 +208,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[data, selectedItem, viewMode, onMouseEnter, handleOpenURL]
|
||||
[data, selectedItem, viewMode, onMouseEnter]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -238,7 +227,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{data?.list && data.list.length > 0 ? (
|
||||
<div ref={containerRef} style={{ height: '100%' }}>
|
||||
<div ref={containerRef} style={{ height: "100%" }}>
|
||||
<FixedSizeList
|
||||
ref={listRef}
|
||||
height={containerRef.current?.clientHeight || 400}
|
||||
@@ -250,8 +239,13 @@ export const DocumentList: React.FC<DocumentListProps> = ({
|
||||
if (!scrollUpdateWasRequested && containerRef.current) {
|
||||
const threshold = 100;
|
||||
const { scrollHeight, clientHeight } = containerRef.current;
|
||||
const remainingScroll = scrollHeight - (scrollOffset + clientHeight);
|
||||
if (remainingScroll <= threshold && !loading && data?.hasMore) {
|
||||
const remainingScroll =
|
||||
scrollHeight - (scrollOffset + clientHeight);
|
||||
if (
|
||||
remainingScroll <= threshold &&
|
||||
!loading &&
|
||||
data?.hasMore
|
||||
) {
|
||||
data?.loadMore && data.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { OpenURLWithBrowser } from "@/utils/index";
|
||||
type ISearchData = Record<string, any[]>;
|
||||
|
||||
interface DropdownListProps {
|
||||
selected: (item: any) => void;
|
||||
suggests: any[];
|
||||
SearchData: ISearchData;
|
||||
IsError: boolean;
|
||||
@@ -23,7 +22,6 @@ interface DropdownListProps {
|
||||
}
|
||||
|
||||
function DropdownList({
|
||||
selected,
|
||||
suggests,
|
||||
SearchData,
|
||||
IsError,
|
||||
@@ -110,8 +108,6 @@ function DropdownList({
|
||||
const item = globalItemIndexMap[selectedItem];
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,12 +116,10 @@ function DropdownList({
|
||||
const item = globalItemIndexMap[parseInt(e.key, 10)];
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}
|
||||
},
|
||||
[suggests, selectedItem, showIndex, selected, globalItemIndexMap]
|
||||
[suggests, selectedItem, showIndex, globalItemIndexMap]
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
||||
@@ -158,7 +152,6 @@ function DropdownList({
|
||||
|
||||
function goToTwoPage(item: any) {
|
||||
setSourceData(item);
|
||||
selected && selected(item);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -219,8 +212,6 @@ function DropdownList({
|
||||
onItemClick={() => {
|
||||
if (item?.url) {
|
||||
OpenURLWithBrowser(item?.url);
|
||||
} else {
|
||||
selected(item);
|
||||
}
|
||||
}}
|
||||
goToTwoPage={goToTwoPage}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { isImage } from "@/utils";
|
||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||
import { filesize } from "filesize";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const FileList = () => {
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
|
||||
|
||||
const deleteFile = (id: string) => {
|
||||
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
|
||||
{uploadFiles.map((file) => {
|
||||
const { id, path, icon, name, extname, size } = file;
|
||||
|
||||
return (
|
||||
<div key={id} className="w-1/3 px-1">
|
||||
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
|
||||
<div
|
||||
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
|
||||
onClick={() => {
|
||||
deleteFile(id);
|
||||
}}
|
||||
>
|
||||
<X className="size-[10px] text-white" />
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={convertFileSrc(isImage(path) ? path : icon)}
|
||||
className="size-[40px]"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden">
|
||||
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
|
||||
{name}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-[#999999]">
|
||||
<div className="flex gap-2">
|
||||
{extname && <span>{extname}</span>}
|
||||
<span>
|
||||
{filesize(size, { standard: "jedec", spacer: "" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileList;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import clsx from "clsx";
|
||||
|
||||
import logoImg from "@/assets/icon.svg";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
@@ -10,27 +9,29 @@ 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 {
|
||||
isChat: boolean;
|
||||
name?: string;
|
||||
openSetting: () => void;
|
||||
setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function Footer({}: FooterProps) {
|
||||
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);
|
||||
|
||||
function openSetting() {
|
||||
emit("open_settings", "");
|
||||
}
|
||||
const setVisible = useUpdateStore((state) => state.setVisible);
|
||||
const updateInfo = useUpdateStore((state) => state.updateInfo);
|
||||
|
||||
const togglePin = async () => {
|
||||
try {
|
||||
const newPinned = !isPinned;
|
||||
await getCurrentWindow().setAlwaysOnTop(newPinned);
|
||||
await setWindowAlwaysOnTop(newPinned);
|
||||
setIsPinned(newPinned);
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle window pin state:", err);
|
||||
@@ -55,16 +56,26 @@ export default function Footer({}: FooterProps) {
|
||||
alt={t("search.footer.logoAlt")}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{sourceData?.source?.name ||
|
||||
<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",
|
||||
})}
|
||||
</span>
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={togglePin}
|
||||
className={`${isPinned ? "text-blue-500" : ""}`}
|
||||
className={clsx({
|
||||
"text-blue-500": isPinned,
|
||||
"pl-2": updateInfo?.available,
|
||||
})}
|
||||
>
|
||||
{isPinned ? <PinIcon /> : <PinOffIcon />}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ArrowBigLeft, Search, Send, Brain } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
|
||||
@@ -13,6 +11,13 @@ import { useAppStore } from "@/stores/appStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { metaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import SearchPopover from "./SearchPopover";
|
||||
// import AudioRecording from "../AudioRecording";
|
||||
import { hide_coco } from "@/commands";
|
||||
import { DataSource } from "@/types/commands";
|
||||
// import InputExtra from "./InputExtra";
|
||||
// import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useKeyPress } from "ahooks";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string) => void;
|
||||
@@ -28,6 +33,19 @@ interface ChatInputProps {
|
||||
isDeepThinkActive: boolean;
|
||||
setIsDeepThinkActive: () => void;
|
||||
isChatPage?: boolean;
|
||||
getDataSourcesByServer: (serverId: string) => Promise<DataSource[]>;
|
||||
setupWindowFocusListener: (callback: () => void) => Promise<() => void>;
|
||||
checkScreenPermission: () => Promise<boolean>;
|
||||
requestScreenPermission: () => void;
|
||||
getScreenMonitors: () => Promise<any[]>;
|
||||
getScreenWindows: () => Promise<any[]>;
|
||||
captureMonitorScreenshot: (id: number) => Promise<string>;
|
||||
captureWindowScreenshot: (id: number) => Promise<string>;
|
||||
openFileDialog: (options: {
|
||||
multiple: boolean;
|
||||
}) => Promise<string | string[] | null>;
|
||||
getFileMetadata: (path: string) => Promise<any>;
|
||||
getFileIcon: (path: string, size: number) => Promise<string>;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
@@ -44,7 +62,18 @@ export default function ChatInput({
|
||||
isDeepThinkActive,
|
||||
setIsDeepThinkActive,
|
||||
isChatPage = false,
|
||||
}: ChatInputProps) {
|
||||
getDataSourcesByServer,
|
||||
setupWindowFocusListener,
|
||||
}: // checkScreenPermission,
|
||||
// requestScreenPermission,
|
||||
// getScreenMonitors,
|
||||
// getScreenWindows,
|
||||
// captureMonitorScreenshot,
|
||||
// captureWindowScreenshot,
|
||||
// openFileDialog,
|
||||
// getFileMetadata,
|
||||
// getFileIcon,
|
||||
ChatInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showTooltip = useAppStore(
|
||||
@@ -60,6 +89,16 @@ export default function ChatInput({
|
||||
(state: { setSourceData: any }) => state.setSourceData
|
||||
);
|
||||
|
||||
// const sessionId = useConnectStore((state) => state.currentSessionId);
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||
const returnToInput = useShortcutsStore((state) => state.returnToInput);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
changeInput("");
|
||||
@@ -74,7 +113,38 @@ export default function ChatInput({
|
||||
|
||||
const { curChatEnd, connected } = useChatStore();
|
||||
|
||||
const [reconnectCountdown, setReconnectCountdown] = useState<number>(0);
|
||||
useEffect(() => {
|
||||
if (!reconnectCountdown || connected) {
|
||||
setReconnectCountdown(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectCountdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setReconnectCountdown(reconnectCountdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [reconnectCountdown, connected]);
|
||||
|
||||
const [isCommandPressed, setIsCommandPressed] = useState(false);
|
||||
const setModifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.setModifierKeyPressed;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
setIsCommandPressed(false);
|
||||
setModifierKeyPressed(false);
|
||||
};
|
||||
|
||||
window.addEventListener("focus", handleFocus);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleToggleFocus = useCallback(() => {
|
||||
if (isChatMode) {
|
||||
@@ -98,10 +168,12 @@ export default function ChatInput({
|
||||
if (inputValue) {
|
||||
changeInput("");
|
||||
} else if (!isPinned) {
|
||||
invoke("hide_coco").then(() => console.log("Hide Coco"));
|
||||
hide_coco();
|
||||
}
|
||||
}, [inputValue, isPinned]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${returnToInput}`, handleToggleFocus);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// console.log("handleKeyDown", e.code, e.key);
|
||||
@@ -123,8 +195,6 @@ export default function ChatInput({
|
||||
case "Comma":
|
||||
setIsCommandPressed(false);
|
||||
break;
|
||||
case "KeyI":
|
||||
handleToggleFocus();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
setSourceData(undefined);
|
||||
@@ -181,24 +251,15 @@ export default function ChatInput({
|
||||
}, [handleKeyDown, handleKeyUp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
const unlisten = await listen("tauri://focus", () => {
|
||||
// console.log("Window focused!");
|
||||
setupWindowFocusListener(() => {
|
||||
if (isChatMode) {
|
||||
textareaRef.current?.focus();
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
};
|
||||
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
setupListener().then((unlistener) => {
|
||||
}).then((unlistener) => {
|
||||
unlisten = unlistener;
|
||||
});
|
||||
|
||||
@@ -212,15 +273,9 @@ export default function ChatInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full relative`}>
|
||||
<div
|
||||
className={`w-full relative ${
|
||||
isChatPage
|
||||
? "bg-inputbox_bg_light dark:bg-inputbox_bg_dark bg-cover rounded-xl border border-[#E6E6E6] dark:border-[#272626]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative `}
|
||||
className={`p-2 flex items-center dark:text-[#D8D8D8] bg-[#ededed] dark:bg-[#202126] rounded transition-all relative overflow-hidden`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 flex-1 items-center relative">
|
||||
{!isChatMode && !sourceData ? (
|
||||
@@ -270,34 +325,23 @@ export default function ChatInput({
|
||||
←
|
||||
</div>
|
||||
) : null}
|
||||
{showTooltip && isCommandPressed ? (
|
||||
{showTooltip && modifierKeyPressed ? (
|
||||
<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]`}
|
||||
>
|
||||
I
|
||||
{returnToInput}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* {isChatMode ? (
|
||||
<button
|
||||
className={`p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition-colors ${
|
||||
isListening ? "bg-blue-100 dark:bg-blue-900" : ""
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Mic
|
||||
className={`w-4 h-4 ${
|
||||
isListening
|
||||
? "text-blue-500 animate-pulse"
|
||||
: "text-[#999] dark:text-[#999]"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
) : null} */}
|
||||
{/* <AudioRecording
|
||||
key={isChatMode ? "chat" : "search"}
|
||||
onChange={(text) => {
|
||||
changeInput(inputValue + text);
|
||||
}}
|
||||
/> */}
|
||||
|
||||
{isChatMode && curChatEnd ? (
|
||||
<button
|
||||
@@ -326,13 +370,13 @@ export default function ChatInput({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{showTooltip && isChatMode && isCommandPressed ? (
|
||||
{/* {showTooltip && isChatMode && isCommandPressed ? (
|
||||
<div
|
||||
className={`absolute right-10 w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]`}
|
||||
>
|
||||
M
|
||||
</div>
|
||||
) : null}
|
||||
) : null} */}
|
||||
|
||||
{showTooltip && isChatMode && isCommandPressed ? (
|
||||
<div
|
||||
@@ -346,10 +390,15 @@ export default function ChatInput({
|
||||
<div className="absolute top-0 right-0 bottom-0 left-0 px-2 py-4 bg-red-500/10 rounded-md font-normal text-xs text-gray-400 flex items-center gap-4">
|
||||
{t("search.input.connectionError")}
|
||||
<div
|
||||
className="w-[96px] h-[24px] bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
|
||||
onClick={reconnect}
|
||||
className="h-[24px] px-2 bg-[#0061FF] rounded-[12px] font-normal text-xs text-white flex items-center justify-center cursor-pointer"
|
||||
onClick={() => {
|
||||
reconnect();
|
||||
setReconnectCountdown(10);
|
||||
}}
|
||||
>
|
||||
{t("search.input.reconnect")}
|
||||
{reconnectCountdown > 0
|
||||
? `${t("search.input.connecting")}(${reconnectCountdown}s)`
|
||||
: t("search.input.reconnect")}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -361,7 +410,19 @@ export default function ChatInput({
|
||||
>
|
||||
{isChatMode ? (
|
||||
<div className="flex gap-2 text-sm text-[#333] dark:text-[#d8d8d8]">
|
||||
{/* <InputExtra /> */}
|
||||
{/* {sessionId && (
|
||||
<InputExtra
|
||||
checkScreenPermission={checkScreenPermission}
|
||||
requestScreenPermission={requestScreenPermission}
|
||||
getScreenMonitors={getScreenMonitors}
|
||||
getScreenWindows={getScreenWindows}
|
||||
captureMonitorScreenshot={captureMonitorScreenshot}
|
||||
captureWindowScreenshot={captureWindowScreenshot}
|
||||
openFileDialog={openFileDialog}
|
||||
getFileMetadata={getFileMetadata}
|
||||
getFileIcon={getFileIcon}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -393,19 +454,23 @@ export default function ChatInput({
|
||||
<SearchPopover
|
||||
isSearchActive={isSearchActive}
|
||||
setIsSearchActive={setIsSearchActive}
|
||||
getDataSourcesByServer={getDataSourcesByServer}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-28 flex gap-2 relative"></div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="w-28 flex gap-2 relative"
|
||||
></div>
|
||||
)}
|
||||
|
||||
{isChatPage ? null : (
|
||||
<div className="relative w-16 flex justify-end items-center">
|
||||
{showTooltip && isCommandPressed ? (
|
||||
{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]`}
|
||||
>
|
||||
T
|
||||
{modeSwitch}
|
||||
</div>
|
||||
) : null}
|
||||
<ChatSwitch
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Fragment, MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import {
|
||||
Menu,
|
||||
@@ -8,33 +10,20 @@ import {
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from "@headlessui/react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { castArray, find, isNil } from "lodash-es";
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { metadata, icon } from "tauri-plugin-fs-pro-api";
|
||||
import { nanoid } from "nanoid";
|
||||
import Tooltip from "../Common/Tooltip";
|
||||
import { useCreation, useKeyPress, useMount, useReactive } from "ahooks";
|
||||
|
||||
import { useChatStore } from "@/stores/chatStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { useCreation, useMount, useReactive } from "ahooks";
|
||||
import {
|
||||
checkScreenRecordingPermission,
|
||||
requestScreenRecordingPermission,
|
||||
} from "tauri-plugin-macos-permissions-api";
|
||||
import {
|
||||
getScreenshotableMonitors,
|
||||
getScreenshotableWindows,
|
||||
ScreenshotableMonitor,
|
||||
ScreenshotableWindow,
|
||||
getMonitorScreenshot,
|
||||
getWindowScreenshot,
|
||||
} from "tauri-plugin-screenshots-api";
|
||||
import { Fragment, MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Tooltip from "@/components/Common/Tooltip";
|
||||
import { useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface State {
|
||||
screenRecordingPermission?: boolean;
|
||||
screenshotableMonitors: ScreenshotableMonitor[];
|
||||
screenshotableWindows: ScreenshotableWindow[];
|
||||
screenshotableMonitors: any[];
|
||||
screenshotableWindows: any[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -46,11 +35,44 @@ interface MenuItem {
|
||||
clickEvent?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const InputExtra = () => {
|
||||
interface InputExtraProps {
|
||||
checkScreenPermission: () => Promise<boolean>;
|
||||
requestScreenPermission: () => void;
|
||||
getScreenMonitors: () => Promise<any[]>;
|
||||
getScreenWindows: () => Promise<any[]>;
|
||||
captureMonitorScreenshot: (id: number) => Promise<string>;
|
||||
captureWindowScreenshot: (id: number) => Promise<string>;
|
||||
openFileDialog: (options: {
|
||||
multiple: boolean;
|
||||
}) => Promise<string | string[] | null>;
|
||||
getFileMetadata: (path: string) => Promise<any>;
|
||||
getFileIcon: (path: string, size: number) => Promise<string>;
|
||||
}
|
||||
|
||||
const InputExtra = ({
|
||||
checkScreenPermission,
|
||||
requestScreenPermission,
|
||||
getScreenMonitors,
|
||||
getScreenWindows,
|
||||
captureMonitorScreenshot,
|
||||
captureWindowScreenshot,
|
||||
openFileDialog,
|
||||
getFileMetadata,
|
||||
getFileIcon,
|
||||
}: InputExtraProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const uploadFiles = useChatStore((state) => state.uploadFiles);
|
||||
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
|
||||
const setIsPinned = useAppStore((state) => state.setIsPinned);
|
||||
const withVisibility = useAppStore((state) => state.withVisibility);
|
||||
const modifierKey = useShortcutsStore((state) => {
|
||||
return state.modifierKey;
|
||||
});
|
||||
const addFile = useShortcutsStore((state) => {
|
||||
return state.addFile;
|
||||
});
|
||||
const modifierKeyPressed = useShortcutsStore((state) => {
|
||||
return state.modifierKeyPressed;
|
||||
});
|
||||
|
||||
const state = useReactive<State>({
|
||||
screenshotableMonitors: [],
|
||||
@@ -58,16 +80,28 @@ const InputExtra = () => {
|
||||
});
|
||||
|
||||
useMount(async () => {
|
||||
state.screenRecordingPermission = await checkScreenRecordingPermission();
|
||||
state.screenRecordingPermission = await checkScreenPermission();
|
||||
});
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const selectedFiles = await withVisibility(() => {
|
||||
return openFileDialog({
|
||||
multiple: true,
|
||||
});
|
||||
});
|
||||
|
||||
if (isNil(selectedFiles)) return;
|
||||
|
||||
handleUploadFiles(selectedFiles);
|
||||
};
|
||||
|
||||
const handleUploadFiles = async (paths: string | string[]) => {
|
||||
const files: typeof uploadFiles = [];
|
||||
|
||||
for await (const path of castArray(paths)) {
|
||||
if (find(uploadFiles, { path })) continue;
|
||||
|
||||
const stat = await metadata(path);
|
||||
const stat = await getFileMetadata(path);
|
||||
|
||||
if (stat.size / 1024 / 1024 > 100) {
|
||||
continue;
|
||||
@@ -77,7 +111,7 @@ const InputExtra = () => {
|
||||
...stat,
|
||||
id: nanoid(),
|
||||
path,
|
||||
icon: await icon(path, 256),
|
||||
icon: await getFileIcon(path, 256),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,30 +122,18 @@ const InputExtra = () => {
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: t("search.input.uploadFile"),
|
||||
clickEvent: async () => {
|
||||
setIsPinned(true);
|
||||
|
||||
const selectedFiles = await open({
|
||||
multiple: true,
|
||||
});
|
||||
|
||||
setIsPinned(false);
|
||||
|
||||
if (isNil(selectedFiles)) return;
|
||||
|
||||
handleUploadFiles(selectedFiles);
|
||||
},
|
||||
clickEvent: handleSelectFile,
|
||||
},
|
||||
{
|
||||
label: t("search.input.screenshot"),
|
||||
clickEvent: async (event) => {
|
||||
if (state.screenRecordingPermission) {
|
||||
state.screenshotableMonitors = await getScreenshotableMonitors();
|
||||
state.screenshotableWindows = await getScreenshotableWindows();
|
||||
state.screenshotableMonitors = await getScreenMonitors();
|
||||
state.screenshotableWindows = await getScreenWindows();
|
||||
} else {
|
||||
event.preventDefault();
|
||||
|
||||
requestScreenRecordingPermission();
|
||||
requestScreenPermission();
|
||||
}
|
||||
},
|
||||
children: [
|
||||
@@ -124,7 +146,7 @@ const InputExtra = () => {
|
||||
id,
|
||||
label: name,
|
||||
clickEvent: async () => {
|
||||
const path = await getMonitorScreenshot(id);
|
||||
const path = await captureMonitorScreenshot(id);
|
||||
|
||||
handleUploadFiles(path);
|
||||
},
|
||||
@@ -140,7 +162,7 @@ const InputExtra = () => {
|
||||
id,
|
||||
label: name,
|
||||
clickEvent: async () => {
|
||||
const path = await getWindowScreenshot(id);
|
||||
const path = await captureWindowScreenshot(id);
|
||||
|
||||
handleUploadFiles(path);
|
||||
},
|
||||
@@ -158,12 +180,29 @@ const InputExtra = () => {
|
||||
i18n.language,
|
||||
]);
|
||||
|
||||
useKeyPress(`${modifierKey}.${addFile}`, handleSelectFile);
|
||||
|
||||
return (
|
||||
<Menu>
|
||||
<MenuButton>
|
||||
<MenuButton className="size-6">
|
||||
<Tooltip content="支持截图、上传文件,最多 50个,单个文件最大 100 MB。">
|
||||
<div className="size-6 flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<Plus className="size-5" />
|
||||
<div className="size-full flex justify-center items-center rounded-lg transition hover:bg-[#EDEDED] dark:hover:bg-[#202126]">
|
||||
<Plus
|
||||
className={clsx("size-5", {
|
||||
hidden: modifierKeyPressed,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={clsx(
|
||||
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
|
||||
{
|
||||
hidden: !modifierKeyPressed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{addFile}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</MenuButton>
|
||||
|
||||
38
src/components/Search/NoResults.tsx
Normal file
38
src/components/Search/NoResults.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Command } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { isMac } from "@/utils/platform";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
|
||||
export const NoResults = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="h-full w-full flex flex-col items-center"
|
||||
>
|
||||
<img src={noDataImg} alt="no-data" className="w-16 h-16 mt-24" />
|
||||
<div className="mt-4 text-sm text-[#999] dark:text-[#666]">
|
||||
{t("search.main.noResults")}
|
||||
</div>
|
||||
<div className="mt-10 text-sm text-[#333] dark:text-[#D8D8D8] flex">
|
||||
{t("search.main.askCoco")}
|
||||
{isMac ? (
|
||||
<span className="ml-3 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
<Command className="w-3 h-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="ml-3 w-8 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
<span className="h-3 leading-3 inline-flex items-center text-xs">
|
||||
Ctrl
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 w-5 h-5 rounded-[6px] border border-[#D8D8D8] flex justify-center items-center">
|
||||
T
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,43 +1,50 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { Command } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
import DropdownList from "./DropdownList";
|
||||
import Footer from "./Footer";
|
||||
import noDataImg from "@/assets/coconut-tree.png";
|
||||
import { SearchResults } from "@/components/Search/SearchResults";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import { NoResults } from "./NoResults";
|
||||
|
||||
interface SearchProps {
|
||||
changeInput: (val: string) => void;
|
||||
isChatMode: boolean;
|
||||
input: string;
|
||||
querySearch: (input: string) => Promise<any>;
|
||||
queryDocuments: (
|
||||
from: number,
|
||||
size: number,
|
||||
queryStrings: any
|
||||
) => Promise<any>;
|
||||
hideCoco: () => Promise<any>;
|
||||
openSetting: () => void;
|
||||
setWindowAlwaysOnTop: (isPinned: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
function Search({ isChatMode, input }: SearchProps) {
|
||||
const { t } = useTranslation();
|
||||
function Search({
|
||||
isChatMode,
|
||||
input,
|
||||
querySearch,
|
||||
queryDocuments,
|
||||
hideCoco,
|
||||
openSetting,
|
||||
setWindowAlwaysOnTop,
|
||||
}: SearchProps) {
|
||||
const sourceData = useSearchStore((state) => state.sourceData);
|
||||
|
||||
const [IsError, setIsError] = useState<boolean>(false);
|
||||
const [suggests, setSuggests] = useState<any[]>([]);
|
||||
const [SearchData, setSearchData] = useState<any>({});
|
||||
const [isSearchComplete, setIsSearchComplete] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<any>();
|
||||
|
||||
const mainWindowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getSuggest = async () => {
|
||||
if (!input) return;
|
||||
try {
|
||||
const response: any = await invoke("query_coco_fusion", {
|
||||
from: 0,
|
||||
size: 10,
|
||||
queryStrings: { query: input },
|
||||
});
|
||||
const response = await querySearch(input);
|
||||
|
||||
console.log("_suggest", input, response);
|
||||
let data = response?.hits || [];
|
||||
@@ -79,7 +86,11 @@ function Search({ isChatMode, input }: SearchProps) {
|
||||
{/* Search Results Panel */}
|
||||
{suggests.length > 0 ? (
|
||||
sourceData ? (
|
||||
<SearchResults input={input} isChatMode={isChatMode} />
|
||||
<SearchResults
|
||||
input={input}
|
||||
isChatMode={isChatMode}
|
||||
queryDocuments={queryDocuments}
|
||||
/>
|
||||
) : (
|
||||
<DropdownList
|
||||
suggests={suggests}
|
||||
@@ -87,41 +98,18 @@ function Search({ isChatMode, input }: SearchProps) {
|
||||
IsError={IsError}
|
||||
isSearchComplete={isSearchComplete}
|
||||
isChatMode={isChatMode}
|
||||
selected={(item) => setSelectedItem(item)}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
<NoResults />
|
||||
)}
|
||||
|
||||
<Footer isChat={false} name={selectedItem?.source?.name} />
|
||||
<Footer
|
||||
openSetting={openSetting}
|
||||
setWindowAlwaysOnTop={setWindowAlwaysOnTop}
|
||||
/>
|
||||
|
||||
<ContextMenu />
|
||||
<ContextMenu hideCoco={hideCoco} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,25 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
Checkbox,
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
} from "@headlessui/react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
RefreshCw,
|
||||
Layers,
|
||||
CheckIcon,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||
import { ChevronDownIcon, RefreshCw, Layers, Globe } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import TypeIcon from "@/components/Common/Icons/TypeIcon";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
import { useSearchStore } from "@/stores/searchStore";
|
||||
|
||||
interface DataSource {
|
||||
id: string;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
import { DataSource } from "@/types/commands";
|
||||
import Checkbox from "../Common/Checkbox";
|
||||
|
||||
interface SearchPopoverProps {
|
||||
isSearchActive: boolean;
|
||||
setIsSearchActive: () => void;
|
||||
getDataSourcesByServer: (serverId: string) => Promise<DataSource[]>;
|
||||
}
|
||||
|
||||
export default function SearchPopover({
|
||||
isSearchActive,
|
||||
setIsSearchActive,
|
||||
getDataSourcesByServer,
|
||||
}: SearchPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isRefreshDataSource, setIsRefreshDataSource] = useState(false);
|
||||
@@ -46,9 +32,9 @@ export default function SearchPopover({
|
||||
|
||||
const getDataSourceList = useCallback(async () => {
|
||||
try {
|
||||
const res: DataSource[] = await invoke("get_datasources_by_server", {
|
||||
id: currentService?.id,
|
||||
});
|
||||
const res: DataSource[] = await getDataSourcesByServer(
|
||||
currentService?.id
|
||||
);
|
||||
const data = [
|
||||
{
|
||||
id: "all",
|
||||
@@ -126,7 +112,7 @@ export default function SearchPopover({
|
||||
|
||||
{dataSourceList?.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverButton className={clsx("flex items-center")}>
|
||||
<PopoverButton as="span" className={clsx("flex items-center")}>
|
||||
<ChevronDownIcon
|
||||
className={clsx("size-5", [
|
||||
isSearchActive
|
||||
@@ -186,7 +172,7 @@ export default function SearchPopover({
|
||||
<TypeIcon item={item} className="size-[16px]" />
|
||||
)}
|
||||
|
||||
<span>{isAll ? t(name) : name}</span>
|
||||
<span>{isAll && name ? t(name) : name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center size-[24px]">
|
||||
@@ -197,19 +183,11 @@ export default function SearchPopover({
|
||||
dataSourceList.length - 1
|
||||
: sourceDataIds?.includes(id)
|
||||
}
|
||||
indeterminate={isAll}
|
||||
onChange={(value) =>
|
||||
onSelectDataSource(id, value, isAll)
|
||||
}
|
||||
className="group size-[14px] rounded-sm border border-black/30 dark:border-white/30 data-[checked]:bg-[#2F54EB] data-[checked]:!border-[#2F54EB] transition cursor-pointer"
|
||||
>
|
||||
{isAll && (
|
||||
<div className="size-full flex items-center justify-center group-data-[checked]:hidden">
|
||||
<div className="size-[6px] bg-[#2F54EB]"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CheckIcon className="hidden size-[12px] text-white group-data-[checked]:block" />
|
||||
</Checkbox>
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { DocumentDetail } from "./DocumentDetail";
|
||||
interface SearchResultsProps {
|
||||
input: string;
|
||||
isChatMode: boolean;
|
||||
queryDocuments: (from: number, size: number, queryStrings: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export function SearchResults({ input, isChatMode }: SearchResultsProps) {
|
||||
export function SearchResults({ input, isChatMode, queryDocuments }: SearchResultsProps) {
|
||||
const [selectedDocumentId, setSelectedDocumentId] = useState("1");
|
||||
|
||||
const [detailData, setDetailData] = useState<any>({});
|
||||
@@ -30,6 +31,7 @@ export function SearchResults({ input, isChatMode }: SearchResultsProps) {
|
||||
isChatMode={isChatMode}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
queryDocuments={queryDocuments}
|
||||
/>
|
||||
|
||||
{/* Right Panel */}
|
||||
|
||||
150
src/components/Settings/Advanced/components/Shortcuts/index.tsx
Normal file
150
src/components/Settings/Advanced/components/Shortcuts/index.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { ModifierKey, useShortcutsStore } from "@/stores/shortcutsStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatKey } from "@/utils/keyboardUtils";
|
||||
import SettingsItem from "@/components/Settings/SettingsItem";
|
||||
import { Command } from "lucide-react";
|
||||
import { ChangeEvent, useEffect } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { isMac } from "@/utils/platform";
|
||||
|
||||
export const modifierKeys: ModifierKey[] = isMac
|
||||
? ["meta", "ctrl"]
|
||||
: ["ctrl", "alt"];
|
||||
|
||||
const Shortcuts = () => {
|
||||
const { t } = useTranslation();
|
||||
const modifierKey = useShortcutsStore((state) => state.modifierKey);
|
||||
const setModifierKey = useShortcutsStore((state) => state.setModifierKey);
|
||||
const modeSwitch = useShortcutsStore((state) => state.modeSwitch);
|
||||
const setModeSwitch = useShortcutsStore((state) => state.setModeSwitch);
|
||||
const returnToInput = useShortcutsStore((state) => state.returnToInput);
|
||||
const setReturnToInput = useShortcutsStore((state) => state.setReturnToInput);
|
||||
const voiceInput = useShortcutsStore((state) => state.voiceInput);
|
||||
const setVoiceInput = useShortcutsStore((state) => state.setVoiceInput);
|
||||
// const addImage = useShortcutsStore((state) => state.addImage);
|
||||
// const setAddImage = useShortcutsStore((state) => state.setAddImage);
|
||||
// const selectLlmModel = useShortcutsStore((state) => state.selectLlmModel);
|
||||
// const setSelectLlmModel = useShortcutsStore((state) => {
|
||||
// return state.setSelectLlmModel;
|
||||
// });
|
||||
const addFile = useShortcutsStore((state) => state.addFile);
|
||||
const setAddFile = useShortcutsStore((state) => state.setAddFile);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = useShortcutsStore.subscribe((state) => {
|
||||
emit("change-shortcuts-store", state);
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
}, []);
|
||||
|
||||
const list = [
|
||||
{
|
||||
title: "settings.advanced.shortcuts.modeSwitch.title",
|
||||
description: "settings.advanced.shortcuts.modeSwitch.description",
|
||||
value: modeSwitch,
|
||||
setValue: setModeSwitch,
|
||||
},
|
||||
{
|
||||
title: "settings.advanced.shortcuts.returnToInput.title",
|
||||
description: "settings.advanced.shortcuts.returnToInput.description",
|
||||
value: returnToInput,
|
||||
setValue: setReturnToInput,
|
||||
},
|
||||
{
|
||||
title: "settings.advanced.shortcuts.voiceInput.title",
|
||||
description: "settings.advanced.shortcuts.voiceInput.description",
|
||||
value: voiceInput,
|
||||
setValue: setVoiceInput,
|
||||
},
|
||||
// {
|
||||
// title: "settings.advanced.shortcuts.addImage.title",
|
||||
// description: "settings.advanced.shortcuts.addImage.description",
|
||||
// value: addImage,
|
||||
// setValue: setAddImage,
|
||||
// },
|
||||
// {
|
||||
// title: "settings.advanced.shortcuts.selectLlmModel.title",
|
||||
// description: "settings.advanced.shortcuts.selectLlmModel.description",
|
||||
// value: selectLlmModel,
|
||||
// setValue: setSelectLlmModel,
|
||||
// },
|
||||
{
|
||||
title: "settings.advanced.shortcuts.addFile.title",
|
||||
description: "settings.advanced.shortcuts.addFile.description",
|
||||
value: addFile,
|
||||
setValue: setAddFile,
|
||||
},
|
||||
];
|
||||
|
||||
const handleChange = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
setValue: (value: string) => void
|
||||
) => {
|
||||
const value = event.target.value.toUpperCase();
|
||||
|
||||
if (value.length > 1) return;
|
||||
|
||||
const state = useShortcutsStore.getState();
|
||||
|
||||
if (Object.values(state).includes(value)) return;
|
||||
|
||||
setValue(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t("settings.advanced.shortcuts.title")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsItem
|
||||
icon={Command}
|
||||
title={t("settings.advanced.shortcuts.modifierKey.title")}
|
||||
description={t("settings.advanced.shortcuts.modifierKey.description")}
|
||||
>
|
||||
<select
|
||||
value={modifierKey}
|
||||
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onChange={(event) => {
|
||||
setModifierKey(event.target.value as ModifierKey);
|
||||
}}
|
||||
>
|
||||
{modifierKeys.map((item) => {
|
||||
return <option value={item}>{formatKey(item)}</option>;
|
||||
})}
|
||||
</select>
|
||||
</SettingsItem>
|
||||
|
||||
{list.map((item) => {
|
||||
const { title, description, value, setValue } = item;
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
key={title}
|
||||
icon={Command}
|
||||
title={t(title)}
|
||||
description={t(description)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatKey(modifierKey)}</span>
|
||||
<span>+</span>
|
||||
<input
|
||||
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10"
|
||||
value={value}
|
||||
maxLength={1}
|
||||
onChange={(event) => {
|
||||
handleChange(event, setValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shortcuts;
|
||||
170
src/components/Settings/Advanced/index.tsx
Normal file
170
src/components/Settings/Advanced/index.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Shortcuts from "./components/Shortcuts";
|
||||
import SettingsItem from "../SettingsItem";
|
||||
import { AppWindowMac, MessageSquareMore, Search, Unplug } from "lucide-react";
|
||||
import { useStartupStore } from "@/stores/startupStore";
|
||||
import { useEffect } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
const Advanced = () => {
|
||||
const { t } = useTranslation();
|
||||
const defaultStartupWindow = useStartupStore((state) => {
|
||||
return state.defaultStartupWindow;
|
||||
});
|
||||
const setDefaultStartupWindow = useStartupStore((state) => {
|
||||
return state.setDefaultStartupWindow;
|
||||
});
|
||||
const defaultContentForSearchWindow = useStartupStore((state) => {
|
||||
return state.defaultContentForSearchWindow;
|
||||
});
|
||||
const setDefaultContentForSearchWindow = useStartupStore((state) => {
|
||||
return state.setDefaultContentForSearchWindow;
|
||||
});
|
||||
const defaultContentForChatWindow = useStartupStore((state) => {
|
||||
return state.defaultContentForChatWindow;
|
||||
});
|
||||
const setDefaultContentForChatWindow = useStartupStore((state) => {
|
||||
return state.setDefaultContentForChatWindow;
|
||||
});
|
||||
const connectionTimeout = useConnectStore((state) => {
|
||||
return state.connectionTimeout;
|
||||
});
|
||||
const setConnectionTimeout = useConnectStore((state) => {
|
||||
return state.setConnectionTimeout;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = useStartupStore.subscribe((state) => {
|
||||
emit("change-startup-store", state);
|
||||
});
|
||||
|
||||
return unlisten;
|
||||
}, []);
|
||||
|
||||
const startupList = [
|
||||
{
|
||||
icon: AppWindowMac,
|
||||
title: "settings.advanced.startup.defaultStartupWindow.title",
|
||||
description: "settings.advanced.startup.defaultStartupWindow.description",
|
||||
value: defaultStartupWindow,
|
||||
items: [
|
||||
{
|
||||
label:
|
||||
"settings.advanced.startup.defaultStartupWindow.select.searchMode",
|
||||
value: "searchMode",
|
||||
},
|
||||
{
|
||||
label:
|
||||
"settings.advanced.startup.defaultStartupWindow.select.chatMode",
|
||||
value: "chatMode",
|
||||
},
|
||||
],
|
||||
onChange: setDefaultStartupWindow,
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
title: "settings.advanced.startup.defaultContentForSearchWindow.title",
|
||||
description:
|
||||
"settings.advanced.startup.defaultContentForSearchWindow.description",
|
||||
value: defaultContentForSearchWindow,
|
||||
items: [
|
||||
{
|
||||
label:
|
||||
"settings.advanced.startup.defaultContentForSearchWindow.select.systemDefault",
|
||||
value: "systemDefault",
|
||||
},
|
||||
],
|
||||
onChange: setDefaultContentForSearchWindow,
|
||||
},
|
||||
{
|
||||
icon: MessageSquareMore,
|
||||
title: "settings.advanced.startup.defaultContentForChatWindow.title",
|
||||
description:
|
||||
"settings.advanced.startup.defaultContentForChatWindow.description",
|
||||
value: defaultContentForChatWindow,
|
||||
items: [
|
||||
{
|
||||
label:
|
||||
"settings.advanced.startup.defaultContentForChatWindow.select.newChat",
|
||||
value: "newChat",
|
||||
},
|
||||
{
|
||||
label:
|
||||
"settings.advanced.startup.defaultContentForChatWindow.select.oldChat",
|
||||
value: "oldChat",
|
||||
},
|
||||
],
|
||||
onChange: setDefaultContentForChatWindow,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t("settings.advanced.startup.title")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{startupList.map((item) => {
|
||||
const { icon, title, description, value, items, onChange } = item;
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
key={title}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
description={t(description)}
|
||||
>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value as never);
|
||||
}}
|
||||
className="px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{items.map((item) => {
|
||||
const { label, value } = item;
|
||||
|
||||
return (
|
||||
<option key={value} value={value}>
|
||||
{t(label)}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</SettingsItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Shortcuts />
|
||||
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t("settings.advanced.connect.title")}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<SettingsItem
|
||||
icon={Unplug}
|
||||
title={t("settings.advanced.connect.connectionTimeout.title")}
|
||||
description={t(
|
||||
"settings.advanced.connect.connectionTimeout.description"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
value={connectionTimeout}
|
||||
className="w-20 h-8 px-2 rounded-md border bg-transparent border-black/5 dark:border-white/10"
|
||||
onChange={(event) => {
|
||||
setConnectionTimeout(Number(event.target.value) || 120);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Advanced;
|
||||
@@ -11,10 +11,9 @@ import {
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isTauri, invoke } from "@tauri-apps/api/core";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import {
|
||||
isEnabled,
|
||||
// enable, disable
|
||||
} from "@tauri-apps/plugin-autostart";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCreation } from "ahooks";
|
||||
@@ -27,6 +26,7 @@ import { useShortcutEditor } from "@/hooks/useShortcutEditor";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { AppTheme } from "@/utils/tauri";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { change_autostart, get_current_shortcut, change_shortcut, unregister_shortcut } from "@/commands"
|
||||
|
||||
export function ThemeOption({
|
||||
icon: Icon,
|
||||
@@ -90,8 +90,7 @@ export default function GeneralSettings() {
|
||||
const enableAutoStart = async () => {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
// await enable();
|
||||
invoke("change_autostart", { open: true });
|
||||
await change_autostart(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to enable autostart:", error);
|
||||
}
|
||||
@@ -102,8 +101,7 @@ export default function GeneralSettings() {
|
||||
const disableAutoStart = async () => {
|
||||
if (isTauri()) {
|
||||
try {
|
||||
// await disable();
|
||||
invoke("change_autostart", { open: false });
|
||||
await change_autostart(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to disable autostart:", error);
|
||||
}
|
||||
@@ -123,7 +121,7 @@ export default function GeneralSettings() {
|
||||
|
||||
async function getCurrentShortcut() {
|
||||
try {
|
||||
const res: any = await invoke("get_current_shortcut");
|
||||
const res: any = await get_current_shortcut();
|
||||
// console.log("get_current_shortcut: ", res);
|
||||
setShortcut(res?.split("+"));
|
||||
} catch (err) {
|
||||
@@ -143,7 +141,7 @@ export default function GeneralSettings() {
|
||||
setShortcut(key);
|
||||
//
|
||||
if (key.length === 0) return;
|
||||
invoke("change_shortcut", { key: key?.join("+") }).catch((err) => {
|
||||
change_shortcut(key?.join("+")).catch((err) => {
|
||||
console.error("Failed to save hotkey:", err);
|
||||
});
|
||||
};
|
||||
@@ -154,7 +152,7 @@ export default function GeneralSettings() {
|
||||
const onEditShortcut = async () => {
|
||||
startEditing();
|
||||
//
|
||||
invoke("unregister_shortcut").catch((err) => {
|
||||
unregister_shortcut().catch((err) => {
|
||||
console.error("Failed to save hotkey:", err);
|
||||
});
|
||||
};
|
||||
@@ -162,7 +160,7 @@ export default function GeneralSettings() {
|
||||
const onCancelShortcut = async () => {
|
||||
cancelEditing();
|
||||
//
|
||||
invoke("change_shortcut", { key: shortcut?.join("+") }).catch((err) => {
|
||||
change_shortcut(shortcut?.join("+")).catch((err) => {
|
||||
console.error("Failed to save hotkey:", err);
|
||||
});
|
||||
};
|
||||
|
||||
BIN
src/components/UpdateApp/imgs/dark-icon.png
Executable file
BIN
src/components/UpdateApp/imgs/dark-icon.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/components/UpdateApp/imgs/light-icon.png
Normal file
BIN
src/components/UpdateApp/imgs/light-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
185
src/components/UpdateApp/index.tsx
Normal file
185
src/components/UpdateApp/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, Dialog, DialogPanel } from "@headlessui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { noop } from "lodash-es";
|
||||
import { LoaderCircle, X } from "lucide-react";
|
||||
import { useInterval, useReactive } from "ahooks";
|
||||
import clsx from "clsx";
|
||||
|
||||
import lightIcon from "./imgs/light-icon.png";
|
||||
import darkIcon from "./imgs/dark-icon.png";
|
||||
import { useThemeStore } from "@/stores/themeStore";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import { OpenURLWithBrowser } from "@/utils/index";
|
||||
|
||||
interface State {
|
||||
loading?: boolean;
|
||||
total?: number;
|
||||
download: number;
|
||||
}
|
||||
|
||||
interface UpdateAppProps {
|
||||
checkUpdate: () => Promise<any>;
|
||||
relaunchApp: () => Promise<void>;
|
||||
}
|
||||
|
||||
const UpdateApp = ({ checkUpdate, relaunchApp }: UpdateAppProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isDark = useThemeStore((state) => state.isDark);
|
||||
const visible = useUpdateStore((state) => state.visible);
|
||||
const setVisible = useUpdateStore((state) => state.setVisible);
|
||||
const skipVersion = useUpdateStore((state) => state.skipVersion);
|
||||
const setSkipVersion = useUpdateStore((state) => state.setSkipVersion);
|
||||
const isOptional = useUpdateStore((state) => state.isOptional);
|
||||
const updateInfo = useUpdateStore((state) => state.updateInfo);
|
||||
const setUpdateInfo = useUpdateStore((state) => state.setUpdateInfo);
|
||||
|
||||
const state = useReactive<State>({ download: 0 });
|
||||
|
||||
useInterval(() => checkUpdateStatus(), 1000 * 60 * 60 * 24, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
const checkUpdateStatus = useCallback(async () => {
|
||||
const update = await checkUpdate();
|
||||
|
||||
if (update?.available) {
|
||||
setUpdateInfo(update);
|
||||
|
||||
if (skipVersion === update.version) return;
|
||||
|
||||
setVisible(true);
|
||||
}
|
||||
}, [skipVersion]);
|
||||
|
||||
const cursorClassName = useMemo(() => {
|
||||
return state.loading ? "cursor-not-allowed" : "cursor-pointer";
|
||||
}, [state.loading]);
|
||||
|
||||
const percent = useMemo(() => {
|
||||
const { total, download } = state;
|
||||
|
||||
if (!total) return 0;
|
||||
|
||||
return ((download / total) * 100).toFixed(2);
|
||||
}, [state.total, state.download]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (state.loading) return;
|
||||
|
||||
state.loading = true;
|
||||
|
||||
await updateInfo?.downloadAndInstall((progress) => {
|
||||
switch (progress.event) {
|
||||
case "Started":
|
||||
state.total = progress.data.contentLength;
|
||||
break;
|
||||
case "Progress":
|
||||
state.download += progress.data.chunkLength;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
state.loading = false;
|
||||
|
||||
relaunchApp();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (state.loading) return;
|
||||
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
if (state.loading) return;
|
||||
|
||||
setSkipVersion(updateInfo?.version);
|
||||
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={visible}
|
||||
as="div"
|
||||
className="relative z-10 focus:outline-none"
|
||||
onClose={noop}
|
||||
>
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative w-[340px] py-8 flex flex-col items-center bg-white shadow-md border border-[#EDEDED] rounded-lg dark:bg-[#333] dark:border-black/20"
|
||||
>
|
||||
<X
|
||||
className={clsx(
|
||||
"absolute size-5 text-[#999] top-3 right-3 dark:text-[#D8D8D8]",
|
||||
cursorClassName,
|
||||
{
|
||||
hidden: !isOptional,
|
||||
}
|
||||
)}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
|
||||
<img src={isDark ? darkIcon : lightIcon} className="h-6" />
|
||||
|
||||
<div className="text-[#333] text-sm leading-5 py-2 dark:text-[#D8D8D8]">
|
||||
{isOptional ? (
|
||||
t("update.optional_description")
|
||||
) : (
|
||||
<div className="leading-5 text-center">
|
||||
<p>{t("update.force_description1")}</p>
|
||||
<p>{t("update.force_description2")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-xs text-[#0072FF] cursor-pointer"
|
||||
onClick={() => {
|
||||
OpenURLWithBrowser(
|
||||
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
|
||||
);
|
||||
}}
|
||||
>
|
||||
v{updateInfo?.version} {t("update.releaseNotes")}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className={clsx(
|
||||
"mb-3 mt-6 bg-[#0072FF] text-white text-sm px-[14px] py-[8px] rounded-lg",
|
||||
cursorClassName,
|
||||
{
|
||||
"opacity-50": state.loading,
|
||||
}
|
||||
)}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{state.loading ? (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<LoaderCircle className="animate-spin size-5" />
|
||||
{percent}%
|
||||
</div>
|
||||
) : (
|
||||
t("update.button.download")
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div
|
||||
className={clsx("text-xs text-[#999]", cursorClassName, {
|
||||
hidden: !isOptional,
|
||||
})}
|
||||
onClick={handleSkip}
|
||||
>
|
||||
{t("update.skip_version")}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateApp;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useRouteError } from "react-router-dom";
|
||||
|
||||
import errorImg from "./assets/error_page.png";
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error: any = useRouteError();
|
||||
console.error(error);
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen bg-white shadow-[0px_16px_32px_0px_rgba(0,0,0,0.4)] rounded-xl border-[2px] border-[#E6E6E6] m-auto">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<img
|
||||
src={errorImg}
|
||||
alt="error-page"
|
||||
className="w-[221px] h-[154px] mb-8 mt-[72px]"
|
||||
/>
|
||||
<div className="w-[380px] h-[46px] px-5 font-normal text-base text-[rgba(0,0,0,0.85)] leading-[25px] text-center mb-4">
|
||||
Sorry, there is an error in your Coco App. Please contact the
|
||||
administrator.
|
||||
</div>
|
||||
<div className="w-[380px] h-[45px] font-normal text-[10px] text-[rgba(135,135,135,0.85)] leading-[16px] text-center">
|
||||
<i>{error.statusText || error.message}</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,245 @@
|
||||
import { useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
import { IServer } from "@/stores/appStore";
|
||||
import type { Chat } from "@/components/Assistant/types";
|
||||
import { close_session_chat, cancel_session_chat, session_chat_history, new_chat, send_message, open_session_chat, chat_history } from "@/commands"
|
||||
|
||||
export default function useChatActions(currentService: IServer, activeChat?: Chat) {
|
||||
const chatClose = useCallback(async () => {
|
||||
if (!activeChat?._id) return;
|
||||
export function useChatActions(
|
||||
currentServiceId: string | undefined,
|
||||
setActiveChat: (chat: Chat | undefined) => void,
|
||||
setCurChatEnd: (value: boolean) => void,
|
||||
setErrorShow: (value: boolean) => void,
|
||||
setTimedoutShow: (value: boolean) => void,
|
||||
clearAllChunkData: () => void,
|
||||
setQuestion: (value: string) => void,
|
||||
curIdRef: React.MutableRefObject<string>,
|
||||
isSearchActive?: boolean,
|
||||
isDeepThinkActive?: boolean,
|
||||
sourceDataIds?: string[],
|
||||
changeInput?: (val: string) => void,
|
||||
websocketSessionId?: string,
|
||||
) {
|
||||
const chatClose = useCallback(async (activeChat?: Chat) => {
|
||||
if (!activeChat?._id || !currentServiceId) return;
|
||||
try {
|
||||
await invoke("close_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
let response: any = await close_session_chat({
|
||||
serverId: currentServiceId,
|
||||
sessionId: activeChat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_close", response);
|
||||
} catch (error) {
|
||||
console.error("Failed to close chat:", error);
|
||||
console.error("chatClose:", error);
|
||||
}
|
||||
}, [currentService?.id, activeChat?._id]);
|
||||
}, [currentServiceId]);
|
||||
|
||||
const cancelChat = useCallback(async () => {
|
||||
if (!activeChat?._id) return;
|
||||
const cancelChat = useCallback(async (activeChat?: Chat) => {
|
||||
setCurChatEnd(true);
|
||||
if (!activeChat?._id || !currentServiceId) return;
|
||||
try {
|
||||
await invoke("cancel_session_chat", {
|
||||
serverId: currentService?.id,
|
||||
let response: any = await cancel_session_chat({
|
||||
serverId: currentServiceId,
|
||||
sessionId: activeChat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_cancel", response);
|
||||
} catch (error) {
|
||||
console.error("Failed to cancel chat:", error);
|
||||
console.error("cancelChat:", error);
|
||||
}
|
||||
}, [currentService?.id, activeChat?._id]);
|
||||
}, [currentServiceId, setCurChatEnd]);
|
||||
|
||||
return { chatClose, cancelChat };
|
||||
const chatHistory = useCallback(async (
|
||||
chat: Chat,
|
||||
callback?: (chat: Chat) => void
|
||||
) => {
|
||||
if (!chat?._id || !currentServiceId) return;
|
||||
try {
|
||||
let response: any = await session_chat_history({
|
||||
serverId: currentServiceId,
|
||||
sessionId: chat?._id,
|
||||
from: 0,
|
||||
size: 20,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
const hits = response?.hits?.hits || [];
|
||||
const updatedChat: Chat = {
|
||||
...chat,
|
||||
messages: hits,
|
||||
};
|
||||
console.log("id_history", response, updatedChat);
|
||||
setActiveChat(updatedChat);
|
||||
callback && callback(updatedChat);
|
||||
} catch (error) {
|
||||
console.error("chatHistory:", error);
|
||||
}
|
||||
}, [currentServiceId, setActiveChat]);
|
||||
|
||||
const createNewChat = useCallback(
|
||||
async (value: string = "", activeChat?: Chat, id?: string) => {
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
chatClose(activeChat);
|
||||
clearAllChunkData();
|
||||
setQuestion(value);
|
||||
if (!currentServiceId) return;
|
||||
try {
|
||||
if (!(websocketSessionId || id)){
|
||||
setErrorShow(true);
|
||||
console.error("websocketSessionId", websocketSessionId, id);
|
||||
return;
|
||||
}
|
||||
console.log("sourceDataIds", sourceDataIds, websocketSessionId, id);
|
||||
let response: any = await new_chat({
|
||||
serverId: currentServiceId,
|
||||
websocketId: websocketSessionId || id,
|
||||
message: value,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds?.join(",") || "",
|
||||
},
|
||||
});
|
||||
console.log("_new", response);
|
||||
const newChat: Chat = response;
|
||||
curIdRef.current = response?.payload?.id;
|
||||
|
||||
newChat._source = {
|
||||
message: value,
|
||||
};
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [newChat],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("createNewChat:", error);
|
||||
}
|
||||
},
|
||||
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, websocketSessionId]
|
||||
);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string, newChat: Chat, id?: string) => {
|
||||
if (!newChat?._id || !currentServiceId || !content) return;
|
||||
|
||||
clearAllChunkData();
|
||||
try {
|
||||
if (!(websocketSessionId || id)){
|
||||
setErrorShow(true);
|
||||
console.error("websocketSessionId", websocketSessionId, id);
|
||||
return;
|
||||
}
|
||||
let response: any = await send_message({
|
||||
serverId: currentServiceId,
|
||||
websocketId: websocketSessionId || id,
|
||||
sessionId: newChat?._id,
|
||||
queryParams: {
|
||||
search: isSearchActive,
|
||||
deep_thinking: isDeepThinkActive,
|
||||
datasource: sourceDataIds?.join(",") || "",
|
||||
},
|
||||
message: content,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_send", response);
|
||||
curIdRef.current = response[0]?._id;
|
||||
|
||||
const updatedChat: Chat = {
|
||||
...newChat,
|
||||
messages: [...(newChat?.messages || []), ...(response || [])],
|
||||
};
|
||||
|
||||
changeInput && changeInput("");
|
||||
setActiveChat(updatedChat);
|
||||
setCurChatEnd(false);
|
||||
} catch (error) {
|
||||
setErrorShow(true);
|
||||
console.error("sendMessage:", error);
|
||||
}
|
||||
},
|
||||
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, setActiveChat, setCurChatEnd, setErrorShow, changeInput, websocketSessionId]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (content: string, activeChat?: Chat, id?: string) => {
|
||||
if (!activeChat?._id || !content) return;
|
||||
setQuestion(content);
|
||||
|
||||
setTimedoutShow(false);
|
||||
setErrorShow(false);
|
||||
|
||||
await chatHistory(activeChat, (chat) => sendMessage(content, chat, id));
|
||||
},
|
||||
[chatHistory, sendMessage, setQuestion, setTimedoutShow, setErrorShow, clearAllChunkData]
|
||||
);
|
||||
|
||||
const openSessionChat = useCallback(async (chat: Chat) => {
|
||||
if (!chat?._id || !currentServiceId) return;
|
||||
try {
|
||||
let response: any = await open_session_chat({
|
||||
serverId: currentServiceId,
|
||||
sessionId: chat?._id,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_open", response);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("open_session_chat:", error);
|
||||
return null;
|
||||
}
|
||||
}, [currentServiceId]);
|
||||
|
||||
const getChatHistory = useCallback(async () => {
|
||||
if (!currentServiceId) return [];
|
||||
try {
|
||||
let response: any = await chat_history({
|
||||
serverId: currentServiceId,
|
||||
from: 0,
|
||||
size: 20,
|
||||
});
|
||||
response = JSON.parse(response || "");
|
||||
console.log("_history", response);
|
||||
const hits = response?.hits?.hits || [];
|
||||
return hits;
|
||||
} catch (error) {
|
||||
console.error("chat_history:", error);
|
||||
return [];
|
||||
}
|
||||
}, [currentServiceId]);
|
||||
|
||||
const createChatWindow = useCallback(async (createWin: any) => {
|
||||
if (isTauri()) {
|
||||
createWin && createWin({
|
||||
label: "chat",
|
||||
title: "Coco Chat",
|
||||
dragDropEnabled: true,
|
||||
center: true,
|
||||
width: 1000,
|
||||
height: 800,
|
||||
minWidth: 1000,
|
||||
minHeight: 800,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
decorations: true,
|
||||
closable: true,
|
||||
url: "/ui/chat",
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chatClose,
|
||||
cancelChat,
|
||||
chatHistory,
|
||||
createNewChat,
|
||||
sendMessage,
|
||||
handleSendMessage,
|
||||
openSessionChat,
|
||||
getChatHistory,
|
||||
createChatWindow
|
||||
};
|
||||
}
|
||||
69
src/hooks/useChatScroll.ts
Normal file
69
src/hooks/useChatScroll.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
|
||||
export function useChatScroll(messagesEndRef: React.RefObject<HTMLDivElement>) {
|
||||
const [userScrolling, setUserScrolling] = useState(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
const lastScrollHeightRef = useRef<number>(0);
|
||||
|
||||
const isNearBottom = (container: HTMLElement) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
return Math.abs(scrollHeight - scrollTop - clientHeight) < 150;
|
||||
};
|
||||
|
||||
const scrollToBottom = useCallback(
|
||||
debounce(() => {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
const contentChanged = lastScrollHeightRef.current !== container.scrollHeight;
|
||||
lastScrollHeightRef.current = container.scrollHeight;
|
||||
|
||||
if (!userScrolling || (contentChanged && isNearBottom(container))) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, 50),
|
||||
[userScrolling, messagesEndRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesEndRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
lastScrollHeightRef.current = container.scrollHeight;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
const near = isNearBottom(container);
|
||||
if (!near) {
|
||||
setUserScrolling(true);
|
||||
}
|
||||
|
||||
scrollTimeoutRef.current = setTimeout(() => {
|
||||
if (isNearBottom(container)) {
|
||||
setUserScrolling(false);
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [messagesEndRef]);
|
||||
|
||||
return {
|
||||
userScrolling,
|
||||
scrollToBottom
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import {useEffect} from "react";
|
||||
import {invoke, isTauri} from "@tauri-apps/api/core";
|
||||
import {listen} from "@tauri-apps/api/event";
|
||||
import { useEffect } from "react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { hide_coco } from "@/commands"
|
||||
|
||||
const useEscape = () => {
|
||||
const handleEscape = async (event: KeyboardEvent) => {
|
||||
@@ -10,7 +12,7 @@ const useEscape = () => {
|
||||
event.preventDefault();
|
||||
|
||||
// Hide the Tauri app window when 'Esc' is pressed
|
||||
await invoke("hide_coco");
|
||||
await hide_coco()
|
||||
|
||||
console.log("App window hidden successfully.");
|
||||
}
|
||||
|
||||
95
src/hooks/useMessageHandler.ts
Normal file
95
src/hooks/useMessageHandler.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
|
||||
import type { IChunkData, Chat } from "@/components/Assistant/types";
|
||||
import { useConnectStore } from "@/stores/connectStore";
|
||||
|
||||
export function useMessageHandler(
|
||||
curIdRef: React.MutableRefObject<string>,
|
||||
setCurChatEnd: (value: boolean) => void,
|
||||
setTimedoutShow: (value: boolean) => void,
|
||||
onCancel: (chat?: Chat) => void,
|
||||
setLoadingStep: (
|
||||
value:
|
||||
| Record<string, boolean>
|
||||
| ((prev: Record<string, boolean>) => Record<string, boolean>)
|
||||
) => void,
|
||||
handlers: {
|
||||
deal_query_intent: (data: IChunkData) => void;
|
||||
deal_fetch_source: (data: IChunkData) => void;
|
||||
deal_pick_source: (data: IChunkData) => void;
|
||||
deal_deep_read: (data: IChunkData) => void;
|
||||
deal_think: (data: IChunkData) => void;
|
||||
deal_response: (data: IChunkData) => void;
|
||||
}
|
||||
) {
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout>();
|
||||
const connectionTimeout = useConnectStore((state) => state.connectionTimeout);
|
||||
|
||||
const dealMsg = useCallback(
|
||||
(msg: string) => {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (!msg.includes("PRIVATE")) return;
|
||||
|
||||
messageTimeoutRef.current = setTimeout(() => {
|
||||
console.log("AI response timeout");
|
||||
setTimedoutShow(true);
|
||||
onCancel();
|
||||
}, (connectionTimeout ?? 120) * 1000);
|
||||
|
||||
const cleanedData = msg.replace(/^PRIVATE /, "");
|
||||
try {
|
||||
const chunkData = JSON.parse(cleanedData);
|
||||
|
||||
if (chunkData.reply_to_message !== curIdRef.current) return;
|
||||
|
||||
setLoadingStep(() => ({
|
||||
query_intent: false,
|
||||
fetch_source: false,
|
||||
pick_source: false,
|
||||
deep_read: false,
|
||||
think: false,
|
||||
response: false,
|
||||
[chunkData.chunk_type]: true,
|
||||
}));
|
||||
|
||||
if (chunkData.chunk_type === "query_intent") {
|
||||
handlers.deal_query_intent(chunkData);
|
||||
} else if (chunkData.chunk_type === "fetch_source") {
|
||||
handlers.deal_fetch_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "pick_source") {
|
||||
handlers.deal_pick_source(chunkData);
|
||||
} else if (chunkData.chunk_type === "deep_read") {
|
||||
handlers.deal_deep_read(chunkData);
|
||||
} else if (chunkData.chunk_type === "think") {
|
||||
handlers.deal_think(chunkData);
|
||||
} else if (chunkData.chunk_type === "response") {
|
||||
handlers.deal_response(chunkData);
|
||||
} else if (chunkData.chunk_type === "reply_end") {
|
||||
if (messageTimeoutRef.current) {
|
||||
clearTimeout(messageTimeoutRef.current);
|
||||
}
|
||||
setCurChatEnd(true);
|
||||
console.log("AI finished output");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("parse error:", error);
|
||||
}
|
||||
},
|
||||
[
|
||||
onCancel,
|
||||
setCurChatEnd,
|
||||
setTimedoutShow,
|
||||
curIdRef.current,
|
||||
connectionTimeout,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
dealMsg,
|
||||
messageTimeoutRef,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { listen, emit } from "@tauri-apps/api/event";
|
||||
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
|
||||
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
interface CreateWindowOptions {
|
||||
label?: string;
|
||||
@@ -41,13 +40,13 @@ export default function useSettingsWindow() {
|
||||
};
|
||||
|
||||
// Check if the window already exists
|
||||
WebviewWindow.getByLabel(options.label!).then((existingWindow) => {
|
||||
platformAdapter.getWindowByLabel(options.label!).then((existingWindow) => {
|
||||
if (existingWindow) {
|
||||
existingWindow.show();
|
||||
existingWindow.setFocus();
|
||||
existingWindow.center();
|
||||
} else {
|
||||
new WebviewWindow(options.label!, options);
|
||||
platformAdapter.createWindow(options.label!, options);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
@@ -68,11 +67,11 @@ export default function useSettingsWindow() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen("open_settings", async (event) => {
|
||||
const unlisten = platformAdapter.listenEvent("open_settings", async (event) => {
|
||||
console.log("open_settings event received:", event);
|
||||
const tab = event.payload as string | "";
|
||||
|
||||
emit("tab_index", tab);
|
||||
platformAdapter.emitEvent("tab_index", tab);
|
||||
openSettingsWindow(tab);
|
||||
});
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TrayIcon, type TrayIconOptions } from "@tauri-apps/api/tray";
|
||||
import { Menu, MenuItem, PredefinedMenuItem } from "@tauri-apps/api/menu";
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { resolveResource } from "@tauri-apps/api/path";
|
||||
import { useUpdateEffect } from "ahooks";
|
||||
import { exit } from "@tauri-apps/plugin-process";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { isMac } from "@/utils/platform";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { show_coco, show_settings } from "@/commands";
|
||||
|
||||
const TRAY_ID = "COCO_TRAY";
|
||||
|
||||
@@ -50,7 +51,7 @@ export const useTray = () => {
|
||||
text: t("tray.showCoco"),
|
||||
accelerator: showCocoShortcuts.join("+"),
|
||||
action: () => {
|
||||
invoke("show_coco");
|
||||
show_coco()
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
@@ -58,7 +59,7 @@ export const useTray = () => {
|
||||
text: t("tray.settings"),
|
||||
// accelerator: "CommandOrControl+,",
|
||||
action: () => {
|
||||
invoke("show_settings");
|
||||
show_settings()
|
||||
},
|
||||
}),
|
||||
PredefinedMenuItem.new({ item: "Separator" }),
|
||||
|
||||
@@ -1,66 +1,104 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { IServer } from "@/stores/appStore";
|
||||
import { connect_to_server, disconnect } from "@/commands"
|
||||
|
||||
interface WebSocketProps {
|
||||
clientId: string;
|
||||
connected: boolean;
|
||||
setConnected: (connected: boolean) => void;
|
||||
currentService: IServer | null;
|
||||
dealMsg: (msg: string) => void;
|
||||
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
|
||||
onWebsocketSessionId?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
export default function useWebSocket({
|
||||
clientId,
|
||||
connected,
|
||||
setConnected,
|
||||
currentService,
|
||||
dealMsg
|
||||
dealMsgRef,
|
||||
onWebsocketSessionId,
|
||||
}: WebSocketProps) {
|
||||
const [errorShow, setErrorShow] = useState(false);
|
||||
|
||||
// 1. WebSocket connects when loading or switching services
|
||||
// src/components/Assistant/ChatHeader.tsx
|
||||
// 2. If not connected or disconnected, input box has a connect button, clicking it will connect to WebSocket
|
||||
// src/components/Search/InputBox.tsx
|
||||
const reconnect = useCallback(async (server?: IServer) => {
|
||||
const targetServer = server || currentService;
|
||||
console.log("reconnect_targetServer", targetServer?.id);
|
||||
if (!targetServer?.id) return;
|
||||
try {
|
||||
console.log("reconnect", targetServer.id);
|
||||
await invoke("connect_to_server", { id: targetServer.id });
|
||||
setConnected(true);
|
||||
console.log("reconnect", targetServer.id, clientId);
|
||||
await connect_to_server(targetServer.id, clientId);
|
||||
} catch (error) {
|
||||
setConnected(false);
|
||||
console.error("Failed to connect:", error);
|
||||
}
|
||||
}, [currentService]);
|
||||
|
||||
const disconnectWS = async () => {
|
||||
if (!connected) return;
|
||||
try {
|
||||
console.log("disconnect");
|
||||
await disconnect(clientId);
|
||||
setConnected(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDealMsg = useCallback((newDealMsg: (msg: string) => void) => {
|
||||
dealMsgRef.current = newDealMsg;
|
||||
}, [dealMsgRef]);
|
||||
|
||||
const websocketIdRef = useRef<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentService?.id) return;
|
||||
|
||||
let unlisten_error = null;
|
||||
let unlisten_message = null;
|
||||
|
||||
if (connected) {
|
||||
setErrorShow(false);
|
||||
unlisten_error = listen("ws-error", (event) => {
|
||||
unlisten_error = listen(`ws-error-${clientId}`, (event) => {
|
||||
// {
|
||||
// "error": {
|
||||
// "reason": "invalid login"
|
||||
// },
|
||||
// "status": 401
|
||||
// }
|
||||
console.log("ws-error", event.payload);
|
||||
console.error("WebSocket error:", event.payload);
|
||||
console.error(`ws-error-${clientId}`, event.payload);
|
||||
setConnected(false);
|
||||
setErrorShow(true);
|
||||
});
|
||||
|
||||
unlisten_message = listen("ws-message", (event) => {
|
||||
dealMsg(String(event.payload));
|
||||
});
|
||||
unlisten_message = listen(`ws-message-${clientId}`, (event) => {
|
||||
const msg = event.payload as string;
|
||||
console.log(`ws-message-${clientId}`, msg);
|
||||
if (msg.includes("websocket-session-id")) {
|
||||
console.log("websocket-session-id:", msg);
|
||||
const sessionId = msg.split(":")[1].trim();
|
||||
websocketIdRef.current = sessionId;
|
||||
console.log("sessionId:", sessionId);
|
||||
setConnected(true);
|
||||
if (onWebsocketSessionId) {
|
||||
onWebsocketSessionId(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
dealMsgRef.current && dealMsgRef.current(msg);
|
||||
});
|
||||
|
||||
|
||||
return () => {
|
||||
unlisten_error?.then((fn: any) => fn());
|
||||
unlisten_message?.then((fn: any) => fn());
|
||||
};
|
||||
}, [connected, dealMsg]);
|
||||
}, [dealMsgRef]);
|
||||
|
||||
return { errorShow, setErrorShow, reconnect };
|
||||
return { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg };
|
||||
}
|
||||
28
src/hooks/useWindowEvents.ts
Normal file
28
src/hooks/useWindowEvents.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import platformAdapter from "@/utils/platformAdapter";
|
||||
|
||||
export function useWindowEvents() {
|
||||
const isPinned = useAppStore((state) => state.isPinned);
|
||||
const visible = useAppStore((state) => state.visible);
|
||||
|
||||
useEffect(() => {
|
||||
const handleBlur = async () => {
|
||||
console.log("Window blurred");
|
||||
if (isPinned || visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
await platformAdapter.hideWindow();
|
||||
console.log("Hide Coco");
|
||||
};
|
||||
|
||||
window.addEventListener("blur", handleBlur);
|
||||
|
||||
// Clean up event listeners on component unmount
|
||||
return () => {
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, [isPinned, visible]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user