23 Commits

Author SHA1 Message Date
ayang
569a61841c v0.3.0 2025-03-31 21:45:50 +08:00
ayang
8b2fc07519 docs: update changelog 2025-03-31 21:44:08 +08:00
ayangweb
bf145c8697 style: commenting out unused variables (#320) 2025-03-31 20:57:51 +08:00
ayangweb
0c3606820c docs: update changelog (#319) 2025-03-31 18:36:37 +08:00
ayangweb
3df86fc1c4 refactor: hide voice input and file upload functions (#318) 2025-03-31 18:35:06 +08:00
ayangweb
d01cbe1541 refactor: different platforms support different modifier keys (#317) 2025-03-31 17:17:39 +08:00
ayangweb
89a763dff7 feat: supports keyboard shortcuts with immediate effect (#316)
* feat: supports keyboard shortcuts with immediate effect

* feat: customize mode switching shortcuts

* refactor: remove the shift

* fix: voice input audio input device number anomaly issue

* feat: support for changing the focus state of the input box

* refactor: shortcuts for handling input box focus separately

* feat: upload file support shortcuts

* refactor: the connection timeout is specified with the variable

* refactor: shortcut keys to modify the input box before displaying modifier keys

* docs: update changelog

* style: remove useless import

* refactor: window focus changes modifier key press status to false

* refactor: correcting errors of judgment

* docs: update changelog
2025-03-31 17:07:34 +08:00
Medcl
0c42a51cb5 chore: support icon url parsed by server (#315)
* chore: support icon url parsed by server

* chore: update to support full url based icon
2025-03-30 22:20:15 +08:00
Medcl
f514e5a5c9 chore: support multi websocket connections (#314)
* chore: temp commit

* chore: add WebSocket session ID to chat message API headers

* chore: add param clientId

* feat: add websocket id

* chore: add debug logs

* chore: add log

* chore: add connecting

* chore: remove partialize

* fix: fix to support multi websocket connection

* chore: update release notes

---------

Co-authored-by: rain <15911122312@163.com>
2025-03-30 19:33:49 +08:00
ayangweb
b3aff2b353 refactor: added the voice to text api (#313)
* refactor: added the voice to text api

* refactor: update field name
2025-03-30 19:28:15 +08:00
ayangweb
bcb92bfd49 refactor: hide apps without icon (#312)
* refactor: hide apps without icon

* docs: update changelog
2025-03-28 17:56:58 +08:00
ayangweb
d9dea0ea38 feat: support for uploading files to the server (#310)
* feat: support for uploading files to the server

* feat: field Internationalization

* refactor: encapsulation attachment-related requests

* feat: support for getting a list of attachments that have been uploaded for a session

* feat: the session displays the number and list of uploaded files

* feat: internalization

* feat: wrapping the Checkbox component

* feat: add checkbox

* feat: support for deleting uploaded files

* feat: support for selecting uploaded files

* refactor: optimize the display of file icons

* refactor: hide file uploads when there is no sessionId
2025-03-28 13:50:14 +08:00
BiggerRain
d2eed4a1c4 refactor: refactor invoke related code (#309)
* refactor: refactor invoke related code

* refactor: refactor invoke related code

* docs: update release notes
2025-03-25 20:57:46 +08:00
ayangweb
c7e547b5fa refactor: encapsulates show and hide methods (#308)
* refactor: encapsulates show and hide methods

* style: remove comments
2025-03-24 17:19:19 +08:00
ayangweb
eadd0988ba chore: eliminate all warnings for rust (#307) 2025-03-24 14:55:35 +08:00
ayangweb
78bc83f38a refactor: all commands methods have been changed to asynchronous (#306) 2025-03-24 14:39:08 +08:00
ayangweb
84d9c6cdf0 refactor: hide voice input when no radio device is available (#305)
* refactor: hide voice input when no radio device is available

* style: delete Printing
2025-03-24 12:00:42 +08:00
BiggerRain
0769545a92 chore: remove lazy (#304) 2025-03-24 12:00:11 +08:00
ayangweb
118eaa55e3 feat: voice input support for search and chat (#302)
* feat: voice input support for search and chat

* chore: add mic-recorder plugin

* refactor: check microphone permission before recording

* feat: realize sound wave effects

* chore: remove mic-recorder plugin
2025-03-24 09:17:09 +08:00
BiggerRain
ef1304ce5e feat: add web pages (#277)
* feat: add web pages

* feat: add web page

* refactor: search page

* feat: add tsup build web componet

* chore: update timeout time

* build: build web page

* build: build search chat

* chore: add web page

* docs: update release note
2025-03-17 16:24:18 +08:00
Medcl
51d3a9d090 chore: remove dmg-background.png (#301) 2025-03-17 15:24:42 +08:00
ayangweb
7d0eced55a refactor: resolving code conflicts (#300) 2025-03-17 09:29:34 +08:00
ayangweb
e81c5bbb6e feat: advanced settings content improvement (#281)
* feat: advanced settings content improvement

* feat: support for switching to the default mode

* refactor: shortcut keys support only one letter

* refactor: fix key reporting errors

* feat: listen for changes to `ShortcutsStore`

* feat: add configuration items for modifier keys

* feat: new connection settings configuration item

* refactor: replacing the connection timeout icon

* refactor: optimized the style of the input box

* refactor: update Icons

* refactor: defaults to last chat
2025-03-17 09:19:59 +08:00
105 changed files with 5138 additions and 2690 deletions

2
.gitignore vendored
View File

@@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
out
src/components/web
# Editor directories and files
# .vscode/*

View File

@@ -7,6 +7,7 @@
"changelogithub",
"clsx",
"codegen",
"dataurl",
"dtolnay",
"dyld",
"elif",

View File

@@ -17,6 +17,25 @@ Information about release notes of Coco Server is provided here.
### 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

View File

@@ -1,11 +1,16 @@
{
"name": "coco",
"private": true,
"version": "0.2.1",
"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,8 +18,9 @@
"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",
@@ -23,9 +29,10 @@
"@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.6.0",
"@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.3",
"mermaid": "^11.5.0",
"nanoid": "^5.1.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hotkeys-hook": "^4.6.1",
@@ -49,21 +56,21 @@
"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.10",
"@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",
@@ -72,6 +79,7 @@
"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"

1099
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

507
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.2.1"
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" }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -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))?;

View File

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

View File

@@ -6,6 +6,7 @@ pub struct ChatRequestMessage {
pub message: Option<String>,
}
#[allow(dead_code)]
pub struct NewChatResponse {
pub _id: String,
pub _source: Source,
@@ -22,4 +23,4 @@ pub struct Source {
pub title: Option<String>,
pub summary: Option<String>,
pub manually_renamed_title: bool,
}
}

View File

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

View File

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

View File

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

View File

@@ -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),
}

View File

@@ -18,8 +18,7 @@ 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;
@@ -32,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();
@@ -42,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,
@@ -53,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();
@@ -123,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();
@@ -132,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");
@@ -144,7 +149,7 @@ pub fn run() {
});
shortcut::enable_shortcut(&app);
// enable_tray(app);
enable_autostart(app);
#[cfg(target_os = "macos")]
@@ -221,7 +226,7 @@ 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 {
crate::server::servers::try_register_server_to_search_source(app_handle.clone(), &server)
@@ -241,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", ());
#[tauri::command]
fn hide_coco(app: tauri::AppHandle) {
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);
}
}
Ok(false) => {
println!("Window is already hidden.");
}
Err(err) => {
eprintln!("Failed to check window visibility: {}", err);
}
}
move_window_to_active_monitor(&window);
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn handle_open_coco(app: &AppHandle) {
#[tauri::command]
async fn hide_coco<R: Runtime>(app: AppHandle<R>) {
if let Some(window) = app.get_window(MAIN_WINDOW_LABEL) {
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();
if let Err(err) = window.hide() {
eprintln!("Failed to hide the window: {}", err);
} else {
println!("Window successfully hidden.");
}
} else {
eprintln!("Main window not found.");
}
}
@@ -368,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")

View File

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

View File

@@ -0,0 +1,151 @@
use super::servers::{get_server_by_id, get_server_token};
use crate::server::http_client::HttpClient;
use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::HashMap, path::PathBuf};
use tauri::command;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
#[derive(Debug, Serialize, Deserialize)]
pub struct UploadAttachmentResponse {
pub acknowledged: bool,
pub attachments: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentSource {
pub id: String,
pub created: String,
pub updated: String,
pub session: String,
pub name: String,
pub icon: String,
pub url: String,
pub size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHit {
pub _index: String,
pub _type: String,
pub _id: String,
pub _score: f64,
pub _source: AttachmentSource,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AttachmentHits {
pub total: Value,
pub max_score: f64,
pub hits: Vec<AttachmentHit>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct GetAttachmentResponse {
pub took: u32,
pub timed_out: bool,
pub _shards: Value,
pub hits: AttachmentHits,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct DeleteAttachmentResponse {
pub _id: String,
pub result: String,
}
#[command]
pub async fn upload_attachment(
server_id: String,
session_id: String,
file_paths: Vec<PathBuf>,
) -> Result<UploadAttachmentResponse, String> {
let mut form = Form::new();
for file_path in file_paths {
let file = File::open(&file_path)
.await
.map_err(|err| err.to_string())?;
let stream = FramedRead::new(file, BytesCodec::new());
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or("Invalid filename")?;
let part =
Part::stream(reqwest::Body::wrap_stream(stream)).file_name(file_name.to_string());
form = form.part("files", part);
}
let server = get_server_by_id(&server_id).ok_or("Server not found")?;
let url = HttpClient::join_url(&server.endpoint, &format!("chat/{}/_upload", session_id));
let token = get_server_token(&server_id).await?;
let mut headers = HashMap::new();
if let Some(token) = token {
headers.insert("X-API-TOKEN".to_string(), token.access_token);
}
let client = reqwest::Client::new();
let response = client
.post(url)
.multipart(form)
.headers((&headers).try_into().map_err(|err| format!("{}", err))?)
.send()
.await
.map_err(|err| err.to_string())?;
if response.status().is_success() {
let result = response
.json::<UploadAttachmentResponse>()
.await
.map_err(|err| err.to_string())?;
Ok(result)
} else {
Err(format!("Upload failed with status: {}", response.status()))
}
}
#[command]
pub async fn get_attachment(
server_id: String,
session_id: String,
) -> Result<GetAttachmentResponse, String> {
let mut query_params = HashMap::new();
query_params.insert("session".to_string(), serde_json::Value::String(session_id));
let response = HttpClient::get(&server_id, "/attachment/_search", Some(query_params)).await?;
if response.status().is_success() {
response
.json::<GetAttachmentResponse>()
.await
.map_err(|e| e.to_string())
} else {
Err(format!("Request failed with status: {}", response.status()))
}
}
#[command]
pub async fn delete_attachment(server_id: String, id: String) -> Result<bool, String> {
let response =
HttpClient::delete(&server_id, &format!("/attachment/{}", id), None, None).await?;
if response.status().is_success() {
response
.json::<DeleteAttachmentResponse>()
.await
.map_err(|e| e.to_string())?
.result
.eq("deleted")
.then_some(true)
.ok_or("Delete operation was not successful".to_string())
} else {
Err(format!("Delete failed with status: {}", response.status()))
}
}

View File

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

View File

@@ -22,6 +22,7 @@ pub fn save_datasource_to_cache(server_id: &str, datasources: Vec<DataSource>) {
cache.insert(server_id.to_string(), datasources_map);
}
#[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,22 +41,21 @@ 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 {
Ok(connectors) => {
// Process connectors only after fetching them
let connectors_map: HashMap<String, DataSource> = connectors
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
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
.into_iter()
.map(|connector| (connector.id.clone(), connector))
.collect();
// dbg!("connectors_map: {:?}", &connectors_map);
connectors_map
}
Err(_e) => {
// dbg!("Failed to get dataSources for server {}: {}", &server.id, e);
HashMap::new()
}
};
let mut new_map = HashMap::new();
for (id, datasource) in connectors.iter() {
@@ -79,21 +79,15 @@ pub async fn refresh_all_datasources<R: Runtime>(app_handle: &AppHandle<R>) -> R
cache.extend(server_map);
cache.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)
})?;

View File

@@ -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>>,
query_params: Option<HashMap<String, Value>>, // Add query parameters
#[allow(dead_code)]
pub async fn delete(
server_id: &str,
path: &str,
custom_headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, 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
}
}

View File

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

View File

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

View File

@@ -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 {
@@ -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()
@@ -513,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

View File

@@ -0,0 +1,41 @@
use crate::server::http_client::HttpClient;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::collections::HashMap;
use tauri::command;
#[derive(Debug, Serialize, Deserialize)]
pub struct TranscriptionResponse {
pub text: String,
}
#[command]
pub async fn transcription(
server_id: String,
audio_type: String,
audio_content: String,
) -> Result<TranscriptionResponse, String> {
let mut query_params = HashMap::new();
query_params.insert("type".to_string(), JsonValue::String(audio_type));
query_params.insert("content".to_string(), JsonValue::String(audio_content));
let response = HttpClient::post(
&server_id,
"/services/audio/transcription",
Some(query_params),
None,
)
.await?;
if response.status().is_success() {
response
.json::<TranscriptionResponse>()
.await
.map_err(|e| e.to_string())
} else {
Err(format!(
"Transcription failed with status: {}",
response.status()
))
}
}

View File

@@ -1,184 +1,132 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use 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() {
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);
},
Some(Err(WsError::ConnectionClosed)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket connection closed by the server.");
break;
},
Some(Err(WsError::Protocol(e))) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Protocol error: {}", e);
break;
},
Some(Err(WsError::Utf8)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("Received invalid UTF-8 data.");
break;
},
Some(Err(_)) => {
let _ = app_handle_clone.emit("ws-error", id);
eprintln!("WebSocket error encountered.");
break;
},
_ => continue,
let ws = &mut *instance.ws_connection.lock().await;
loop {
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let _ = app_handle_clone.emit(&format!("ws-message-{}", client_id_clone), text);
},
Some(Err(_)) | None => {
let _ = app_handle_clone.emit(&format!("ws-error-{}", client_id_clone), id.clone());
break;
}
_ => {}
}
_ = cancel_rx.recv() => {
let _ = app_handle_clone.emit("ws-error", id);
dbg!("Cancelling WebSocket connection");
break;
}
}
_ = cancel_rx.recv() => {
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;
}
Ok(())
}
}

View File

@@ -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;
});
}
}
}

View File

@@ -32,7 +32,9 @@
"windowEffects": {
"effects": [],
"radius": 12
}
},
"visibleOnAllWorkspaces": true,
"alwaysOnTop": true
},
{
"label": "settings",

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

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

View File

@@ -1,9 +1,8 @@
import { fetch } from "@tauri-apps/plugin-http";
import { 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,8 +62,8 @@ 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;
// debug API

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

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

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

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

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

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

29
src/commands/system.ts Normal file
View File

@@ -0,0 +1,29 @@
import { invoke } from '@tauri-apps/api/core';
export function change_autostart(open: boolean): Promise<void> {
return invoke('change_autostart', { open });
}
export function get_current_shortcut(): Promise<string> {
return invoke('get_current_shortcut');
}
export function change_shortcut(key: string): Promise<void> {
return invoke('change_shortcut', { key });
}
export function unregister_shortcut(): Promise<void> {
return invoke('unregister_shortcut');
}
export function hide_coco(): Promise<void> {
return invoke('hide_coco');
}
export function show_coco(): Promise<void> {
return invoke('show_coco');
}
export function show_settings(): Promise<void> {
return invoke('show_settings');
}

View File

@@ -32,6 +32,7 @@ interface ChatAIProps {
isSidebarOpen?: boolean;
clearChatPage?: () => void;
isChatPage?: boolean;
getFileUrl: (path: string) => string;
}
export interface ChatAIRef {
@@ -54,6 +55,7 @@ const ChatAI = memo(
isSidebarOpen = false,
clearChatPage,
isChatPage = false,
getFileUrl,
},
ref
) => {
@@ -87,6 +89,12 @@ const ChatAI = memo(
const [Question, setQuestion] = useState<string>("");
const [websocketSessionId, setWebsocketSessionId] = useState('');
const onWebsocketSessionId = useCallback((sessionId: string) => {
setWebsocketSessionId(sessionId);
}, []);
const {
data: {
query_intent,
@@ -111,12 +119,15 @@ const ChatAI = memo(
const dealMsgRef = useRef<((msg: string) => void) | null>(null);
const { errorShow, setErrorShow, reconnect, updateDealMsg } =
const clientId = isChatPage ? "standalone" : "popup"
const { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg } =
useWebSocket({
clientId,
connected,
setConnected,
currentService,
dealMsgRef,
onWebsocketSessionId,
});
const {
@@ -140,7 +151,8 @@ const ChatAI = memo(
isSearchActive,
isDeepThinkActive,
sourceDataIds,
changeInput
changeInput,
websocketSessionId
);
const { dealMsg, messageTimeoutRef } = useMessageHandler(
@@ -149,7 +161,7 @@ const ChatAI = memo(
setTimedoutShow,
(chat) => cancelChat(chat || activeChat),
setLoadingStep,
handlers
handlers,
);
useEffect(() => {
@@ -181,12 +193,12 @@ const ChatAI = memo(
if (!isLogin) return;
if (!curChatEnd) return;
if (!activeChat?._id) {
createNewChat(value, activeChat);
createNewChat(value, activeChat, websocketSessionId);
} else {
handleSendMessage(value, activeChat);
handleSendMessage(value, activeChat, websocketSessionId);
}
},
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage]
[isLogin, curChatEnd, activeChat, createNewChat, handleSendMessage, websocketSessionId]
);
const { createWin } = useWindows();
@@ -202,6 +214,7 @@ const ChatAI = memo(
chatClose(activeChat);
setActiveChat(undefined);
setCurChatEnd(true);
disconnectWS();
};
}, [chatClose, setCurChatEnd]);
@@ -323,6 +336,7 @@ const ChatAI = memo(
errorShow={errorShow}
Question={Question}
handleSendMessage={(value) => handleSendMessage(value, activeChat)}
getFileUrl={getFileUrl}
/>
) : (
<ConnectPrompt />

View File

@@ -3,10 +3,12 @@ import { useTranslation } from "react-i18next";
import { ChatMessage } from "@/components/ChatMessage";
import { Greetings } from "./Greetings";
import FileList from "@/components/Search/FileList";
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;
@@ -22,6 +24,7 @@ interface ChatContentProps {
errorShow: boolean;
Question: string;
handleSendMessage: (content: string, newChat?: Chat) => void;
getFileUrl: (path: string) => string;
}
export const ChatContent = ({
@@ -38,13 +41,23 @@ export const ChatContent = ({
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(() => {
@@ -67,7 +80,7 @@ export const ChatContent = ({
}, [scrollToBottom]);
return (
<div className="flex flex-col h-full justify-between overflow-hidden">
<div className="relative flex flex-col h-full justify-between overflow-hidden">
<div className="flex-1 w-full overflow-x-hidden overflow-y-auto border-t border-[rgba(0,0,0,0.1)] dark:border-[rgba(255,255,255,0.15)] custom-scrollbar relative">
<Greetings />
@@ -141,11 +154,13 @@ export const ChatContent = ({
<div ref={messagesEndRef} />
</div>
{uploadFiles.length > 0 && (
<div className="max-h-[120px] overflow-auto p-2">
<FileList />
{sessionId && uploadFiles.length > 0 && (
<div key={sessionId} className="max-h-[120px] overflow-auto p-2">
<FileList sessionId={sessionId} getFileUrl={getFileUrl} />
</div>
)}
{sessionId && <SessionFile sessionId={sessionId} />}
</div>
);
};

View File

@@ -17,9 +17,6 @@ import {
PopoverPanel,
} 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 (

View File

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

View File

@@ -0,0 +1,112 @@
import { useEffect, useMemo } from "react";
import { filesize } from "filesize";
import { X } from "lucide-react";
import { useAsyncEffect } from "ahooks";
import { useTranslation } from "react-i18next";
import { useChatStore } from "@/stores/chatStore";
import { useConnectStore } from "@/stores/connectStore";
import { deleteAttachment, uploadAttachment } from "@/api/attachment";
import FileIcon from "../Common/Icons/FileIcon";
interface FileListProps {
sessionId: string;
getFileUrl: (path: string) => string;
}
const FileList = (props: FileListProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const currentService = useConnectStore((state) => state.currentService);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
return () => {
setUploadFiles([]);
};
}, []);
useAsyncEffect(async () => {
if (uploadFiles.length === 0) return;
for await (const item of uploadFiles) {
const { uploaded, path } = item;
if (uploaded) continue;
const attachmentIds = await uploadAttachment({
serverId,
sessionId,
filePaths: [path],
});
if (!attachmentIds) continue;
Object.assign(item, {
uploaded: true,
attachmentId: attachmentIds[0],
});
setUploadFiles(uploadFiles);
}
}, [uploadFiles]);
const deleteFile = async (id: string, attachmentId: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
deleteAttachment({ serverId, id: attachmentId });
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const { id, name, extname, size, uploaded, attachmentId } = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
{attachmentId && (
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
deleteFile(id, attachmentId);
}}
>
<X className="size-[10px] text-white" />
</div>
)}
<FileIcon extname={extname} />
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999999]">
{uploaded ? (
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
) : (
<span>{t("assistant.fileList.uploading")}</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -0,0 +1,160 @@
import {
AttachmentHit,
deleteAttachment,
getAttachment,
} from "@/api/attachment";
import { useConnectStore } from "@/stores/connectStore";
import clsx from "clsx";
import { filesize } from "filesize";
import { Files, Trash2, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import Checkbox from "../Common/Checkbox";
import FileIcon from "../Common/Icons/FileIcon";
interface SessionFileProps {
sessionId: string;
}
const SessionFile = (props: SessionFileProps) => {
const { sessionId } = props;
const { t } = useTranslation();
const currentService = useConnectStore((state) => state.currentService);
const [uploadedFiles, setUploadedFiles] = useState<AttachmentHit[]>([]);
const [visible, setVisible] = useState(false);
const [checkList, setCheckList] = useState<string[]>([]);
const serverId = useMemo(() => {
return currentService.id;
}, [currentService]);
useEffect(() => {
setUploadedFiles([]);
getUploadedFiles();
}, [sessionId]);
const getUploadedFiles = async () => {
const response = await getAttachment({ serverId, sessionId });
setUploadedFiles(response.hits.hits);
};
const handleDelete = async (id: string) => {
const result = await deleteAttachment({ serverId, id });
if (!result) return;
getUploadedFiles();
};
const handleCheckAll = (checked: boolean) => {
if (checked) {
setCheckList(uploadedFiles.map((item) => item._source.id));
} else {
setCheckList([]);
}
};
const handleCheck = (checked: boolean, id: string) => {
if (checked) {
setCheckList([...checkList, id]);
} else {
setCheckList(checkList.filter((item) => item !== id));
}
};
return (
<div
className={clsx("select-none", {
hidden: uploadedFiles.length === 0,
})}
>
<div
className="absolute top-4 right-4 flex items-center justify-center size-8 rounded-lg bg-[#0072FF] cursor-pointer"
onClick={() => {
setVisible(true);
}}
>
<Files className="size-5 text-white" />
<div className="absolute -top-2 -right-2 flex items-center justify-center min-w-4 h-4 px-1 text-white text-xs rounded-full bg-[#3DB954]">
{uploadedFiles.length}
</div>
</div>
<div
className={clsx(
"absolute inset-0 flex flex-col p-4 bg-white dark:bg-black",
{
hidden: !visible,
}
)}
>
<X
className="absolute top-4 right-4 size-5 text-[#999] cursor-pointer"
onClick={() => {
setVisible(false);
}}
/>
<div className="mb-2 text-sm text-[#333] dark:text-[#D8D8D8] font-bold">
{t("assistant.sessionFile.title")}
</div>
<div className="flex items-center justify-between pr-2">
<span className="text-sm text-[#999]">
{t("assistant.sessionFile.description")}
</span>
<Checkbox
indeterminate
checked={checkList.length === uploadedFiles.length}
onChange={handleCheckAll}
/>
</div>
<ul className="flex-1 overflow-auto flex flex-col gap-2 mt-6">
{uploadedFiles.map((item) => {
const { id, name, icon, size } = item._source;
return (
<li
key={id}
className="flex items-center justify-between min-h-12 px-2 rounded-[4px] bg-[#ededed] dark:bg-[#202126]"
>
<div className="flex items-center gap-2">
<FileIcon extname={icon} />
<div>
<div className="text-sm leading-4 text-[#333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999]">
<span>{icon}</span>
<span className="pl-2">
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Trash2
className="size-4 text-[#999] cursor-pointer"
onClick={() => handleDelete(id)}
/>
<Checkbox
checked={checkList.includes(id)}
onChange={(checked) => handleCheck(checked, id)}
/>
</div>
</li>
);
})}
</ul>
</div>
</div>
);
};
export default SessionFile;

View File

@@ -0,0 +1,238 @@
import { useAppStore } from "@/stores/appStore";
import { useKeyPress, useReactive } from "ahooks";
import clsx from "clsx";
import { Check, Loader, Mic, X } from "lucide-react";
import { FC, useEffect, useRef } from "react";
import {
checkMicrophonePermission,
requestMicrophonePermission,
} from "tauri-plugin-macos-permissions-api";
import { useWavesurfer } from "@wavesurfer/react";
import RecordPlugin from "wavesurfer.js/dist/plugins/record.esm.js";
import { transcription } from "@/api/transcription";
import { useConnectStore } from "@/stores/connectStore";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AudioRecordingProps {
onChange?: (text: string) => void;
}
interface State {
audioDevices: MediaDeviceInfo[];
isRecording: boolean;
converting: boolean;
countdown: number;
}
const INITIAL_STATE: State = {
audioDevices: [],
isRecording: false,
converting: false,
countdown: 30,
};
let interval: ReturnType<typeof setInterval>;
const AudioRecording: FC<AudioRecordingProps> = (props) => {
const { onChange } = props;
const state = useReactive({ ...INITIAL_STATE });
const containerRef = useRef<HTMLDivElement>(null);
const recordRef = useRef<RecordPlugin>();
const withVisibility = useAppStore((state) => state.withVisibility);
const currentService = useConnectStore((state) => state.currentService);
const modifierKeyPressed = useShortcutsStore((state) => {
return state.modifierKeyPressed;
});
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
const voiceInput = useShortcutsStore((state) => state.voiceInput);
const { wavesurfer } = useWavesurfer({
container: containerRef,
height: 20,
waveColor: "#0072ff",
progressColor: "#999",
barWidth: 4,
barRadius: 4,
barGap: 2,
});
useEffect(() => {
getAvailableAudioDevices();
return resetState;
}, []);
useEffect(() => {
if (!wavesurfer) return;
const record = wavesurfer.registerPlugin(
RecordPlugin.create({
scrollingWaveform: true,
renderRecordedAudio: false,
})
);
record.on("record-end", (blob) => {
if (!state.converting) return;
const reader = new FileReader();
reader.onloadend = async () => {
const base64Audio = (reader.result as string).split(",")[1];
const response = await transcription({
serverId: currentService.id,
audioType: "mp3",
audioContent: base64Audio,
});
if (!response) return;
onChange?.(response.text);
resetState();
};
reader.readAsDataURL(blob);
});
recordRef.current = record;
}, [wavesurfer]);
useEffect(() => {
if (!state.isRecording) return;
interval = setInterval(() => {
if (state.countdown <= 0) {
handleOk();
}
state.countdown--;
}, 1000);
}, [state.isRecording]);
useKeyPress(`${modifierKey}.${voiceInput}`, () => {
startRecording();
});
const getAvailableAudioDevices = async () => {
state.audioDevices = await RecordPlugin.getAvailableAudioDevices();
};
const resetState = (otherState: Partial<State> = {}) => {
clearInterval(interval);
recordRef.current?.stopRecording();
Object.assign(state, {
...INITIAL_STATE,
...otherState,
audioDevices: state.audioDevices,
});
};
const checkPermission = async () => {
const authorized = await checkMicrophonePermission();
if (authorized) return;
requestMicrophonePermission();
return new Promise(async (resolved) => {
const timer = setInterval(async () => {
const authorized = await checkMicrophonePermission();
if (!authorized) return;
clearInterval(timer);
resolved(true);
}, 500);
});
};
const startRecording = async () => {
await withVisibility(checkPermission);
state.isRecording = true;
recordRef.current?.startRecording();
};
const handleOk = () => {
resetState({ converting: true, countdown: state.countdown });
};
return (
<>
<div
className={clsx(
"p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
{
hidden: state.audioDevices.length === 0,
}
)}
>
<Mic
className={clsx("size-4 text-[#999]", {
hidden: modifierKeyPressed,
})}
onClick={startRecording}
/>
<div
className={clsx(
"w-4 h-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000]",
{
hidden: !modifierKeyPressed,
}
)}
>
{voiceInput}
</div>
</div>
<div
className={clsx(
"absolute inset-0 flex items-center gap-1 px-1 rounded translate-x-full transition-all bg-[#ededed] dark:bg-[#202126]",
{
"!translate-x-0": state.isRecording || state.converting,
}
)}
>
<button
disabled={state.converting}
className={clsx(
"flex items-center justify-center size-6 bg-white dark:bg-black rounded-full transition cursor-pointer",
{
"!cursor-not-allowed opacity-50": state.converting,
}
)}
onClick={() => resetState()}
>
<X className="size-4 text-[#0C0C0C] dark:text-[#999999]" />
</button>
<div className="flex items-center gap-1 flex-1 h-6 px-2 bg-white dark:bg-black rounded-full transition">
<div ref={containerRef} className="flex-1"></div>
<span className="text-xs text-[#333] dark:text-[#999]">
{state.countdown}
</span>
</div>
<button
disabled={state.converting}
className="flex items-center justify-center size-6 text-white bg-[#0072FF] rounded-full transition cursor-pointer"
onClick={handleOk}
>
{state.converting ? (
<Loader className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
</button>
</div>
</>
);
};
export default AudioRecording;

View File

@@ -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";
@@ -29,6 +28,16 @@ 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();
@@ -66,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) => {
@@ -98,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");
}
@@ -111,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)
@@ -126,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
@@ -138,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,
@@ -257,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) => {
@@ -283,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);
@@ -298,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) => {
@@ -316,9 +324,11 @@ export default function Cloud() {
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 });
@@ -391,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>
@@ -490,7 +500,7 @@ export default function Cloud() {
) : null}
</div>
) : (
<Connect setIsConnect={setIsConnect} onAddServer={add_coco_server} />
<Connect setIsConnect={setIsConnect} onAddServer={addServer} />
)}
</main>
</div>

View File

@@ -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);

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useCallback } from "react";
import { 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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,3 @@
import { useOSKeyPress } from "@/hooks/useOSKeyPress";
import { useSearchStore } from "@/stores/searchStore";
import { copyToClipboard, OpenURLWithBrowser } from "@/utils";
import { isMac } from "@/utils/platform";
import { invoke } from "@tauri-apps/api/core";
import {
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();
@@ -50,7 +54,7 @@ const ContextMenu = () => {
setVisibleContextMenu(false);
invoke("hide_coco");
hideCoco();
},
},
{

View File

@@ -1,6 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useInfiniteScroll } from "ahooks";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { FixedSizeList } from "react-window";
@@ -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;
@@ -207,7 +208,7 @@ export const DocumentList: React.FC<DocumentListProps> = ({
</div>
);
},
[data, selectedItem, viewMode, onMouseEnter, OpenURLWithBrowser]
[data, selectedItem, viewMode, onMouseEnter]
);
return (
@@ -226,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}
@@ -238,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();
}
}

View File

@@ -1,59 +0,0 @@
import { useChatStore } from "@/stores/chatStore";
import { isImage } from "@/utils";
import { convertFileSrc } from "@tauri-apps/api/core";
import { filesize } from "filesize";
import { X } from "lucide-react";
const FileList = () => {
const uploadFiles = useChatStore((state) => state.uploadFiles);
const setUploadFiles = useChatStore((state) => state.setUploadFiles);
const deleteFile = (id: string) => {
setUploadFiles(uploadFiles.filter((file) => file.id !== id));
};
return (
<div className="flex flex-wrap gap-y-2 -mx-1 text-sm">
{uploadFiles.map((file) => {
const { id, path, icon, name, extname, size } = file;
return (
<div key={id} className="w-1/3 px-1">
<div className="relative group flex items-center gap-1 p-1 rounded-[4px] bg-[#dedede] dark:bg-[#202126]">
<div
className="absolute flex justify-center items-center size-[14px] bg-red-600 top-0 right-0 rounded-full cursor-pointer translate-x-[5px] -translate-y-[5px] transition opacity-0 group-hover:opacity-100 "
onClick={() => {
deleteFile(id);
}}
>
<X className="size-[10px] text-white" />
</div>
<img
src={convertFileSrc(isImage(path) ? path : icon)}
className="size-[40px]"
/>
<div className="flex flex-col justify-between overflow-hidden">
<div className="truncate text-[#333333] dark:text-[#D8D8D8]">
{name}
</div>
<div className="text-xs text-[#999999]">
<div className="flex gap-2">
{extname && <span>{extname}</span>}
<span>
{filesize(size, { standard: "jedec", spacer: "" })}
</span>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
);
};
export default FileList;

View File

@@ -1,7 +1,6 @@
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
import { 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";
@@ -11,12 +10,16 @@ import { isMac } from "@/utils/platform";
import PinOffIcon from "@/icons/PinOff";
import PinIcon from "@/icons/Pin";
import { useUpdateStore } from "@/stores/updateStore";
import clsx from "clsx";
interface FooterProps {
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);
@@ -25,14 +28,10 @@ export default function Footer({}: FooterProps) {
const setVisible = useUpdateStore((state) => state.setVisible);
const updateInfo = useUpdateStore((state) => state.updateInfo);
function openSetting() {
emit("open_settings", "");
}
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);

View File

@@ -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;
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) => {
setupWindowFocusListener(() => {
if (isChatMode) {
textareaRef.current?.focus();
} else {
inputRef.current?.focus();
}
}).then((unlistener) => {
unlisten = unlistener;
});
@@ -212,11 +273,9 @@ export default function ChatInput({
};
return (
<div
className={`w-full relative`}
>
<div className={`w-full relative`}>
<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 ? (
@@ -266,24 +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 && (
<SpeechToText
onChange={(transcript) => {
changeInput(inputValue + transcript);
}}
/>
)} */}
{/* <AudioRecording
key={isChatMode ? "chat" : "search"}
onChange={(text) => {
changeInput(inputValue + text);
}}
/> */}
{isChatMode && curChatEnd ? (
<button
@@ -312,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
@@ -332,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}
@@ -347,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(
@@ -379,26 +454,23 @@ export default function ChatInput({
<SearchPopover
isSearchActive={isSearchActive}
setIsSearchActive={setIsSearchActive}
getDataSourcesByServer={getDataSourcesByServer}
/>
</div>
) : (
<div data-tauri-drag-region className="w-28 flex gap-2 relative">
{/* <SpeechToText
Icon={AudioLines}
onChange={(transcript) => {
changeInput(inputValue + transcript);
}}
/> */}
</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

View File

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

View File

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

View File

@@ -1,25 +1,37 @@
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);
@@ -32,11 +44,7 @@ function Search({ isChatMode, input }: SearchProps) {
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 || [];
@@ -78,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}
@@ -89,37 +101,15 @@ function Search({ isChatMode, input }: SearchProps) {
/>
)
) : (
<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 />
<Footer
openSetting={openSetting}
setWindowAlwaysOnTop={setWindowAlwaysOnTop}
/>
<ContextMenu />
<ContextMenu hideCoco={hideCoco} />
</div>
);
}

View File

@@ -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",
@@ -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>
);

View File

@@ -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 */}

View File

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

View File

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

View File

@@ -11,10 +11,9 @@ import {
Globe,
} 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);
});
};

View File

@@ -1,97 +0,0 @@
import { useEventListener, useReactive } from "ahooks";
import clsx from "clsx";
import { LucideIcon, Mic } from "lucide-react";
import { FC, useEffect } from "react";
interface SpeechToTextProps {
Icon?: LucideIcon;
onChange?: (transcript: string) => void;
}
let recognition: SpeechRecognition | null = null;
const SpeechToText: FC<SpeechToTextProps> = (props) => {
const { Icon = Mic, onChange } = props;
const state = useReactive({
speaking: false,
});
useEffect(() => {
return destroyRecognition;
}, []);
useEventListener("focusin", (event) => {
const { target } = event;
const isInputElement =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement;
if (state.speaking && isInputElement) {
target.blur();
}
});
const handleSpeak = () => {
if (state.speaking) {
return destroyRecognition();
}
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = "zh-CN";
recognition.onresult = (event) => {
const transcript = [...event.results]
.map((result) => result[0].transcript)
.join("");
onChange?.(transcript);
};
recognition.onerror = destroyRecognition;
recognition.onend = destroyRecognition;
recognition.start();
state.speaking = true;
};
const destroyRecognition = () => {
if (recognition) {
recognition.abort();
recognition.onresult = null;
recognition.onerror = null;
recognition.onend = null;
recognition = null;
}
state.speaking = false;
};
return (
<div
className={clsx(
"p-1 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-full transition cursor-pointer",
{
"bg-blue-100 dark:bg-blue-900": state.speaking,
}
)}
>
<Icon
className={clsx("size-4 text-[#999] dark:text-[#999]", {
"text-blue-500 animate-pulse": state.speaking,
})}
onClick={handleSpeak}
/>
</div>
);
};
export default SpeechToText;

View File

@@ -1,17 +1,16 @@
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 { noop } from "lodash-es";
import { LoaderCircle, X } from "lucide-react";
import { useUpdateStore } from "@/stores/updateStore";
import { useInterval, useReactive } from "ahooks";
import { check } from "@tauri-apps/plugin-updater";
import { useCallback, useMemo } from "react";
import { relaunch } from "@tauri-apps/plugin-process";
import clsx from "clsx";
import { open } from "@tauri-apps/plugin-shell";
import { OpenURLWithBrowser } from "@/utils/index";
interface State {
loading?: boolean;
@@ -19,7 +18,12 @@ interface State {
download: number;
}
const UpdateApp = () => {
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);
@@ -32,12 +36,12 @@ const UpdateApp = () => {
const state = useReactive<State>({ download: 0 });
useInterval(() => checkUpdate(), 1000 * 60 * 60 * 24, {
useInterval(() => checkUpdateStatus(), 1000 * 60 * 60 * 24, {
immediate: true,
});
const checkUpdate = useCallback(async () => {
const update = await check();
const checkUpdateStatus = useCallback(async () => {
const update = await checkUpdate();
if (update?.available) {
setUpdateInfo(update);
@@ -78,7 +82,7 @@ const UpdateApp = () => {
state.loading = false;
relaunch();
relaunchApp();
};
const handleCancel = () => {
@@ -135,7 +139,7 @@ const UpdateApp = () => {
<div
className="text-xs text-[#0072FF] cursor-pointer"
onClick={() => {
open(
OpenURLWithBrowser(
"https://docs.infinilabs.com/coco-app/main/docs/release-notes"
);
}}

View File

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

View File

@@ -1,7 +1,8 @@
import { useCallback } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { isTauri } from "@tauri-apps/api/core";
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 function useChatActions(
currentServiceId: string | undefined,
@@ -16,11 +17,12 @@ export function useChatActions(
isDeepThinkActive?: boolean,
sourceDataIds?: string[],
changeInput?: (val: string) => void,
websocketSessionId?: string,
) {
const chatClose = useCallback(async (activeChat?: Chat) => {
if (!activeChat?._id) return;
if (!activeChat?._id || !currentServiceId) return;
try {
let response: any = await invoke("close_session_chat", {
let response: any = await close_session_chat({
serverId: currentServiceId,
sessionId: activeChat?._id,
});
@@ -33,9 +35,9 @@ export function useChatActions(
const cancelChat = useCallback(async (activeChat?: Chat) => {
setCurChatEnd(true);
if (!activeChat?._id) return;
if (!activeChat?._id || !currentServiceId) return;
try {
let response: any = await invoke("cancel_session_chat", {
let response: any = await cancel_session_chat({
serverId: currentServiceId,
sessionId: activeChat?._id,
});
@@ -50,8 +52,9 @@ export function useChatActions(
chat: Chat,
callback?: (chat: Chat) => void
) => {
if (!chat?._id || !currentServiceId) return;
try {
let response: any = await invoke("session_chat_history", {
let response: any = await session_chat_history({
serverId: currentServiceId,
sessionId: chat?._id,
from: 0,
@@ -72,16 +75,23 @@ export function useChatActions(
}, [currentServiceId, setActiveChat]);
const createNewChat = useCallback(
async (value: string = "", activeChat?: Chat) => {
async (value: string = "", activeChat?: Chat, id?: string) => {
setTimedoutShow(false);
setErrorShow(false);
chatClose(activeChat);
clearAllChunkData();
setQuestion(value);
if (!currentServiceId) return;
try {
console.log("sourceDataIds", sourceDataIds);
let response: any = await invoke("new_chat", {
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,
@@ -109,16 +119,23 @@ export function useChatActions(
console.error("createNewChat:", error);
}
},
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef]
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, websocketSessionId]
);
const sendMessage = useCallback(
async (content: string, newChat: Chat) => {
if (!newChat?._id || !content) return;
async (content: string, newChat: Chat, id?: string) => {
if (!newChat?._id || !currentServiceId || !content) return;
clearAllChunkData();
try {
let response: any = await invoke("send_message", {
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,
@@ -144,25 +161,26 @@ export function useChatActions(
console.error("sendMessage:", error);
}
},
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, setActiveChat, setCurChatEnd, setErrorShow, changeInput]
[currentServiceId, sourceDataIds, isSearchActive, isDeepThinkActive, curIdRef, setActiveChat, setCurChatEnd, setErrorShow, changeInput, websocketSessionId]
);
const handleSendMessage = useCallback(
async (content: string, activeChat?: Chat) => {
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));
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 invoke("open_session_chat", {
let response: any = await open_session_chat({
serverId: currentServiceId,
sessionId: chat?._id,
});
@@ -178,7 +196,7 @@ export function useChatActions(
const getChatHistory = useCallback(async () => {
if (!currentServiceId) return [];
try {
let response: any = await invoke("chat_history", {
let response: any = await chat_history({
serverId: currentServiceId,
from: 0,
size: 20,

View File

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

View File

@@ -1,13 +1,18 @@
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,
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;
@@ -18,6 +23,7 @@ export function useMessageHandler(
}
) {
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const connectionTimeout = useConnectStore((state) => state.connectionTimeout);
const dealMsg = useCallback(
(msg: string) => {
@@ -31,7 +37,7 @@ export function useMessageHandler(
console.log("AI response timeout");
setTimedoutShow(true);
onCancel();
}, 120000);
}, (connectionTimeout ?? 120) * 1000);
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
@@ -73,11 +79,17 @@ export function useMessageHandler(
console.error("parse error:", error);
}
},
[onCancel, setCurChatEnd, setTimedoutShow, curIdRef.current]
[
onCancel,
setCurChatEnd,
setTimedoutShow,
curIdRef.current,
connectionTimeout,
]
);
return {
dealMsg,
messageTimeoutRef
messageTimeoutRef,
};
}
}

View File

@@ -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);

View File

@@ -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" }),

View File

@@ -1,73 +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;
dealMsgRef: React.MutableRefObject<((msg: string) => void) | null>;
onWebsocketSessionId?: (sessionId: string) => void;
}
export default function useWebSocket({
clientId,
connected,
setConnected,
currentService,
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) => {
// {
// "error": {
// "reason": "invalid login"
// },
// "status": 401
// }
console.log("ws-error", event.payload);
console.error("WebSocket error:", event.payload);
setConnected(false);
setErrorShow(true);
});
unlisten_message = listen("ws-message", (event) => {
const msg = event.payload as string;
dealMsgRef.current && dealMsgRef.current(msg);
});
}
setErrorShow(false);
unlisten_error = listen(`ws-error-${clientId}`, (event) => {
// {
// "error": {
// "reason": "invalid login"
// },
// "status": 401
// }
console.error(`ws-error-${clientId}`, event.payload);
setConnected(false);
setErrorShow(true);
});
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, dealMsgRef]);
}, [dealMsgRef]);
return { errorShow, setErrorShow, reconnect, updateDealMsg };
return { errorShow, setErrorShow, reconnect, disconnectWS, updateDealMsg };
}

View File

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

View File

@@ -46,6 +46,70 @@
"endpoint": {
"title": "API Endpoint",
"description": "Domain name for interface and websocket"
},
"startup": {
"title": "Startup Settings",
"defaultStartupWindow": {
"title": "Default Startup Window",
"description": "Set whether Coco opens in Search or Chat mode.",
"select": {
"searchMode": "Search Mode",
"chatMode": "Chat Mode"
}
},
"defaultContentForSearchWindow": {
"title": "Default Content for Search Window",
"description": "Set what shows by default in Search mode.",
"select": {
"systemDefault": "System Default"
}
},
"defaultContentForChatWindow": {
"title": "Default Content for Chat Window",
"description": "Set what shows by default in Chat mode.",
"select": {
"newChat": "Start new chat",
"oldChat": "Continue previous chat"
}
}
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"modifierKey": {
"title": "Modifier Key",
"description": "Set the primary modifier key for custom shortcuts."
},
"modeSwitch": {
"title": "Mode Switch",
"description": "Shortcut to toggle between Search and Chat modes."
},
"returnToInput": {
"title": "Return to Input",
"description": "Shortcut to go back to the input area in both Search and Chat modes."
},
"voiceInput": {
"title": "Voice Input",
"description": "Shortcut to activate voice input function in Search mode."
},
"addImage": {
"title": "Add Image",
"description": "Shortcut to quickly add images in Search mode."
},
"selectLlmModel": {
"title": "Select LLM Model",
"description": "Shortcut to select the desired LLM model in Chat mode."
},
"addFile": {
"title": "Add File",
"description": "Shortcut to add files conveniently in Chat mode."
}
},
"connect": {
"title": "Connection Settings",
"connectionTimeout": {
"title": "Connection Timeout",
"description": "Retries the connection if no response is received within this time. Default: 120s."
}
}
},
"tabs": {
@@ -89,6 +153,7 @@
"searchPlaceholder": "Search whatever you want ...",
"connectionError": "Unable to connect to the server",
"reconnect": "Reconnect",
"connecting": "Connecting",
"deepThink": "Deep Think",
"search": "Search",
"uploadFile": "Upload File",
@@ -169,6 +234,14 @@
"source": {
"fetch_source": "Found {{count}} results",
"pick_source": "{{count}} results"
},
"fileList": {
"uploading": "Uploading...",
"uploaded": "Uploaded"
},
"sessionFile": {
"title": "Files in the conversation",
"description": "Only the selected files will participate in the current conversation"
}
},
"cloud": {
@@ -218,5 +291,8 @@
"download": "Download"
},
"skip_version": "Skip this version"
},
"error": {
"message": "Sorry, there is an error in your Coco App. Please contact the administrator."
}
}

View File

@@ -46,6 +46,70 @@
"endpoint": {
"title": "API 接口",
"description": "接口和 WebSocket 的域名"
},
"startup": {
"title": "启动设置",
"defaultStartupWindow": {
"title": "默认启动窗口",
"description": "设置 Coco 是以搜索模式还是聊天模式打开。",
"select": {
"searchMode": "搜索模式",
"chatMode": "聊天模式"
}
},
"defaultContentForSearchWindow": {
"title": "搜索窗口的默认内容",
"description": "设置搜索模式下默认显示的内容。",
"select": {
"systemDefault": "系统默认"
}
},
"defaultContentForChatWindow": {
"title": "聊天窗口的默认内容",
"description": "设置聊天模式下默认显示的内容。",
"select": {
"newChat": "开启新聊天",
"oldChat": "继续之前的聊天"
}
}
},
"shortcuts": {
"title": "键盘快捷键",
"modifierKey": {
"title": "修饰键",
"description": "为自定义快捷键设置主修饰键。"
},
"modeSwitch": {
"title": "模式切换",
"description": "在搜索和聊天模式之间切换的快捷按键。"
},
"returnToInput": {
"title": "返回输入",
"description": "在搜索和聊天模式下返回输入区域的快捷按键。"
},
"voiceInput": {
"title": "语音输入",
"description": "在搜索和聊天模式下激活语音输入功能的快捷按键。"
},
"addImage": {
"title": "添加图片",
"description": "在搜索模式下快速添加图片的快捷按键。"
},
"selectLlmModel": {
"title": "选择 LLM 模型",
"description": "在聊天模式下选择所需 LLM 模型的快捷按键。"
},
"addFile": {
"title": "添加文件",
"description": "在聊天模式下方便地添加文件的快捷按键。"
}
},
"connect": {
"title": "连接设置",
"connectionTimeout": {
"title": "连接超时",
"description": "如果在此时间内未收到响应则重试连接。默认值120 秒。"
}
}
},
"tabs": {
@@ -89,6 +153,7 @@
"searchPlaceholder": "搜索任何内容...",
"connectionError": "无法连接到服务器",
"reconnect": "重新连接",
"connecting": "连接中",
"deepThink": "深度思考",
"search": "联网搜索",
"uploadFile": "上传文件",
@@ -169,6 +234,14 @@
"source": {
"fetch_source": "找到 {{count}} 个结果",
"pick_source": "{{count}} 个结果"
},
"fileList": {
"uploading": "上传中...",
"uploaded": "已上传"
},
"sessionFile": {
"title": "对话中的文件",
"description": "只有选中的文件才会参与当前对话"
}
},
"cloud": {
@@ -217,5 +290,8 @@
"download": "下载"
},
"skip_version": "跳过此版本"
},
"error": {
"message": "抱歉Coco 应用出现了错误。请联系管理员。"
}
}

View File

@@ -1,11 +1,32 @@
import { useState, useRef, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useState, useRef, useEffect, useCallback } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
checkScreenRecordingPermission,
requestScreenRecordingPermission,
} from "tauri-plugin-macos-permissions-api";
import {
getScreenshotableMonitors,
getScreenshotableWindows,
getMonitorScreenshot,
getWindowScreenshot,
} from "tauri-plugin-screenshots-api";
import { open } from "@tauri-apps/plugin-dialog";
import { metadata, icon } from "tauri-plugin-fs-pro-api";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import { Sidebar } from "@/components/Assistant/Sidebar";
import type { Chat } from "@/components/Assistant/types";
import { useConnectStore } from "@/stores/connectStore";
import InputBox from "@/components/Search/InputBox";
import {
chat_history,
session_chat_history,
close_session_chat,
open_session_chat,
get_datasources_by_server,
} from "@/commands";
import { DataSource } from "@/types/commands"
interface ChatProps {}
@@ -24,7 +45,7 @@ export default function Chat({}: ChatProps) {
const [isSearchActive, setIsSearchActive] = useState(false);
const [isDeepThinkActive, setIsDeepThinkActive] = useState(false);
const isChatPage = true
const isChatPage = true;
useEffect(() => {
getChatHistory();
@@ -32,7 +53,7 @@ export default function Chat({}: ChatProps) {
const getChatHistory = async () => {
try {
let response: any = await invoke("chat_history", {
let response: any = await chat_history({
serverId: currentService?.id,
from: 0,
size: 20,
@@ -70,7 +91,7 @@ export default function Chat({}: ChatProps) {
const chatHistory = async (chat: Chat) => {
try {
let response: any = await invoke("session_chat_history", {
let response: any = await session_chat_history({
serverId: currentService?.id,
sessionId: chat?._id,
from: 0,
@@ -92,7 +113,7 @@ export default function Chat({}: ChatProps) {
const chatClose = async () => {
if (!activeChat?._id) return;
try {
let response: any = await invoke("close_session_chat", {
let response: any = await close_session_chat({
serverId: currentService?.id,
sessionId: activeChat?._id,
});
@@ -106,7 +127,7 @@ export default function Chat({}: ChatProps) {
const onSelectChat = async (chat: any) => {
chatClose();
try {
let response: any = await invoke("open_session_chat", {
let response: any = await open_session_chat({
serverId: currentService?.id,
sessionId: chat?._id,
});
@@ -131,6 +152,57 @@ export default function Chat({}: ChatProps) {
chatAIRef.current?.reconnect();
};
const getFileUrl = useCallback((path: string) => {
return convertFileSrc(path);
}, []);
const getDataSourcesByServer = useCallback(
async (serverId: string): Promise<DataSource[]> => {
return get_datasources_by_server(serverId);
},
[]
);
const setupWindowFocusListener = useCallback(async (callback: () => void) => {
return listen("tauri://focus", callback);
}, []);
const checkScreenPermission = useCallback(async () => {
return checkScreenRecordingPermission();
}, []);
const requestScreenPermission = useCallback(() => {
return requestScreenRecordingPermission();
}, []);
const getScreenMonitors = useCallback(async () => {
return getScreenshotableMonitors();
}, []);
const getScreenWindows = useCallback(async () => {
return getScreenshotableWindows();
}, []);
const captureMonitorScreenshot = useCallback(async (id: number) => {
return getMonitorScreenshot(id);
}, []);
const captureWindowScreenshot = useCallback(async (id: number) => {
return getWindowScreenshot(id);
}, []);
const openFileDialog = useCallback(async (options: { multiple: boolean }) => {
return open(options);
}, []);
const getFileMetadata = useCallback(async (path: string) => {
return metadata(path);
}, []);
const getFileIcon = useCallback(async (path: string, size: number) => {
return icon(path, size);
}, []);
return (
<div className="h-screen">
<div className="h-[100%] flex">
@@ -168,11 +240,14 @@ export default function Chat({}: ChatProps) {
isSidebarOpen={isSidebarOpen}
clearChatPage={clearChat}
isChatPage={isChatPage}
getFileUrl={getFileUrl}
changeInput={setInput}
/>
{/* Input area */}
<div className={`border-t p-4 pb-0 border-gray-200 dark:border-gray-800`}>
<div
className={`border-t p-4 pb-0 border-gray-200 dark:border-gray-800`}
>
<InputBox
isChatMode={true}
inputValue={input}
@@ -186,6 +261,17 @@ export default function Chat({}: ChatProps) {
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={() => setIsDeepThinkActive((prev) => !prev)}
isChatPage={isChatPage}
getDataSourcesByServer={getDataSourcesByServer}
setupWindowFocusListener={setupWindowFocusListener}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
</div>
</div>

10
src/pages/error/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { useRouteError } from "react-router-dom";
import { ErrorDisplay } from "@/components/Common/ErrorDisplay";
export default function ErrorPage() {
const error: any = useRouteError();
console.error(error);
return <ErrorDisplay errorMessage={error.statusText || error.message} />;
}

View File

@@ -1,171 +1,136 @@
import { useEffect, useRef, useState } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { LogicalSize } from "@tauri-apps/api/dpi";
import clsx from "clsx";
import { useCallback, useEffect } from "react";
import InputBox from "@/components/Search/InputBox";
import Search from "@/components/Search/Search";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import { isLinux, isWin } from "@/utils/platform";
import UpdateApp from "@/components/UpdateApp";
import SearchChat from "@/pages/web/SearchChat";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useStartupStore } from "@/stores/startupStore";
import { useKeyPress } from "ahooks";
import { modifierKeys } from "@/components/Settings/Advanced/components/Shortcuts";
export default function DesktopApp() {
const initializeListeners = useAppStore((state) => state.initializeListeners);
const initializeListeners_auth = useAuthStore(
(state) => state.initializeListeners
);
const isPinned = useAppStore((state) => state.isPinned);
useEffect(() => {
invoke("get_app_search_source");
function MainApp() {
const querySearch = useCallback(async (input: string) => {
try {
const response: any = await platformAdapter.invokeBackend(
"query_coco_fusion",
{
from: 0,
size: 10,
queryStrings: { query: input },
}
);
return response;
} catch (error) {
console.error("query_coco_fusion error:", error);
throw error;
}
}, []);
useEffect(() => {
initializeListeners();
initializeListeners_auth();
// Listen for window focus and blur events
const handleBlur = async () => {
console.log("Window blurred");
if (isPinned) {
return;
const queryDocuments = useCallback(
async (from: number, size: number, queryStrings: any) => {
try {
const response: any = await platformAdapter.invokeBackend(
"query_coco_fusion",
{
from,
size,
queryStrings,
}
);
return response;
} catch (error) {
console.error("query_coco_fusion error:", error);
throw error;
}
},
[]
);
const modifierKey = useShortcutsStore((state) => {
return state.modifierKey;
});
const setModifierKey = useShortcutsStore((state) => {
return state.setModifierKey;
});
const setModifierKeyPressed = useShortcutsStore((state) => {
return state.setModifierKeyPressed;
});
const setModeSwitch = useShortcutsStore((state) => {
return state.setModeSwitch;
});
const setReturnToInput = useShortcutsStore((state) => {
return state.setReturnToInput;
});
const setVoiceInput = useShortcutsStore((state) => {
return state.setVoiceInput;
});
const setAddImage = useShortcutsStore((state) => {
return state.setAddImage;
});
const setAddFile = useShortcutsStore((state) => {
return state.setAddFile;
});
const setDefaultStartupWindow = useStartupStore((state) => {
return state.setDefaultStartupWindow;
});
const setDefaultContentForSearchWindow = useStartupStore((state) => {
return state.setDefaultContentForSearchWindow;
});
const setDefaultContentForChatWindow = useStartupStore((state) => {
return state.setDefaultContentForChatWindow;
});
invoke("hide_coco").then(() => {
console.log("Hide Coco");
useEffect(() => {
const unListeners = Promise.all([
platformAdapter.listenEvent("change-shortcuts-store", ({ payload }) => {
const {
modifierKey,
modeSwitch,
returnToInput,
voiceInput,
addImage,
addFile,
} = payload;
setModifierKey(modifierKey);
setModeSwitch(modeSwitch);
setReturnToInput(returnToInput);
setVoiceInput(voiceInput);
setAddImage(addImage);
setAddFile(addFile);
}),
platformAdapter.listenEvent("change-startup-store", ({ payload }) => {
const {
defaultStartupWindow,
defaultContentForSearchWindow,
defaultContentForChatWindow,
} = payload;
setDefaultStartupWindow(defaultStartupWindow);
setDefaultContentForSearchWindow(defaultContentForSearchWindow);
setDefaultContentForChatWindow(defaultContentForChatWindow);
}),
]);
return () => {
unListeners.then((fns) => {
fns.forEach((fn) => fn());
});
};
}, []);
const handleFocus = () => {
// Optionally, show the window if needed when focus is regained
// console.log("Window focused");
};
window.addEventListener("blur", handleBlur);
window.addEventListener("focus", handleFocus);
// Clean up event listeners on component unmount
return () => {
window.removeEventListener("blur", handleBlur);
window.removeEventListener("focus", handleFocus);
};
}, [isPinned]);
const chatAIRef = useRef<ChatAIRef>(null);
const [isChatMode, setIsChatMode] = useState(false);
const [input, setInput] = useState("");
const [isTransitioned, setIsTransitioned] = useState(false);
const [isSearchActive, setIsSearchActive] = useState(false);
const [isDeepThinkActive, setIsDeepThinkActive] = useState(false);
async function changeMode(value: boolean) {
setIsChatMode(value);
setIsTransitioned(value);
}
function changeInput(value: string) {
setInput(value);
}
const handleSendMessage = async (value: string) => {
setInput(value);
if (isChatMode) {
if (isTauri()) {
await getCurrentWebviewWindow()?.setSize(new LogicalSize(680, 596));
useKeyPress(
modifierKeys,
(event, key) => {
if (key === modifierKey) {
setModifierKeyPressed(event.type === "keydown");
}
chatAIRef.current?.init(value);
},
{
events: ["keydown", "keyup"],
}
};
const cancelChat = () => {
chatAIRef.current?.cancelChat();
};
const reconnect = () => {
chatAIRef.current?.reconnect();
};
const isTyping = false;
);
return (
<div
data-tauri-drag-region
className={clsx(
"size-full m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center",
[
isTransitioned
? "bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-search_bg_light dark:bg-search_bg_dark",
],
{
"rounded-xl": !isWin,
"border border-[#E6E6E6] dark:border-[#272626]": isLinux,
}
)}
>
<div
data-tauri-drag-region
className={`p-2 pb-0 absolute w-full flex items-center justify-center transition-all duration-500 ${
isTransitioned
? "top-[calc(100vh-90px)] h-[90px] border-t"
: "top-0 h-[90px] border-b"
} border-[#E6E6E6] dark:border-[#272626]`}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={cancelChat}
changeMode={changeMode}
changeInput={changeInput}
reconnect={reconnect}
isSearchActive={isSearchActive}
setIsSearchActive={() => setIsSearchActive((prev) => !prev)}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={() => setIsDeepThinkActive((prev) => !prev)}
/>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[calc(100vh-90px)] `}
>
<Search
key="Search"
input={input}
isChatMode={isChatMode}
changeInput={changeInput}
/>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-all duration-500 select-auto ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[506px] opacity-0 pointer-events-none"
} h-[calc(100vh-90px)]`}
>
{isTransitioned && isChatMode ? (
<ChatAI
ref={chatAIRef}
key="ChatAI"
isTransitioned={isTransitioned}
changeInput={changeInput}
isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive}
/>
) : null}
</div>
<UpdateApp />
</div>
<SearchChat querySearch={querySearch} queryDocuments={queryDocuments} />
);
}
export default MainApp;

View File

@@ -10,6 +10,7 @@ import AboutView from "@/components/Settings/AboutView";
import Cloud from "@/components/Cloud/Cloud.tsx";
import Footer from "@/components/Footer";
import { useTray } from "@/hooks/useTray";
import Advanced from "@/components/Settings/Advanced";
const tabIndexMap: { [key: string]: number } = {
general: 0,
@@ -100,9 +101,7 @@ function SettingsPage() {
</TabPanel>
<TabPanel>
<SettingsPanel title="">
<div className="text-gray-600 dark:text-gray-400">
{t("settings.tabs.advancedContent")}
</div>
<Advanced />
</SettingsPanel>
</TabPanel>
<TabPanel>

View File

@@ -0,0 +1,311 @@
import {
useEffect,
useRef,
useCallback,
useReducer,
Suspense,
memo,
} from "react";
import clsx from "clsx";
import ErrorBoundary from "@/components/Common/ErrorBoundary";
import Search from "@/components/Search/Search";
import InputBox from "@/components/Search/InputBox";
import ChatAI, { ChatAIRef } from "@/components/Assistant/Chat";
import UpdateApp from "@/components/UpdateApp";
import { isLinux, isWin } from "@/utils/platform";
import { appReducer, initialAppState } from "@/reducers/appReducer";
import { useWindowEvents } from "@/hooks/useWindowEvents";
import { useAppStore } from "@/stores/appStore";
import { useAuthStore } from "@/stores/authStore";
import platformAdapter from "@/utils/platformAdapter";
import { useStartupStore } from "@/stores/startupStore";
import { DataSource } from "@/types/commands";
interface SearchChatProps {
querySearch: (input: string) => Promise<any>;
queryDocuments: (
from: number,
size: number,
queryStrings: any
) => Promise<any>;
}
function SearchChat({ querySearch, queryDocuments }: SearchChatProps) {
const [state, dispatch] = useReducer(appReducer, initialAppState);
const {
isChatMode,
input,
isTransitioned,
isSearchActive,
isDeepThinkActive,
isTyping,
} = state;
useWindowEvents();
const initializeListeners = useAppStore((state) => state.initializeListeners);
const initializeListeners_auth = useAuthStore(
(state) => state.initializeListeners
);
useEffect(() => {
initializeListeners();
initializeListeners_auth();
platformAdapter.invokeBackend("get_app_search_source");
}, []);
const chatAIRef = useRef<ChatAIRef>(null);
const changeMode = useCallback(async (value: boolean) => {
dispatch({ type: "SET_CHAT_MODE", payload: value });
localStorage.setItem("coco-chat-mode", String(value));
}, []);
const handleSendMessage = useCallback(
async (value: string) => {
dispatch({ type: "SET_INPUT", payload: value });
if (isChatMode) {
chatAIRef.current?.init(value);
}
},
[isChatMode]
);
const cancelChat = useCallback(() => {
chatAIRef.current?.cancelChat();
}, []);
const reconnect = useCallback(() => {
chatAIRef.current?.reconnect();
}, []);
const setInput = useCallback((value: string) => {
dispatch({ type: "SET_INPUT", payload: value });
}, []);
const toggleSearchActive = useCallback(() => {
dispatch({ type: "TOGGLE_SEARCH_ACTIVE" });
}, []);
const toggleDeepThinkActive = useCallback(() => {
dispatch({ type: "TOGGLE_DEEP_THINK_ACTIVE" });
}, []);
const LoadingFallback = () => (
<div className="flex items-center justify-center h-full">loading...</div>
);
const hideCoco = useCallback(() => {
return platformAdapter.hideWindow();
}, []);
const getFileUrl = useCallback((path: string) => {
return platformAdapter.convertFileSrc(path);
}, []);
const openSetting = useCallback(() => {
return platformAdapter.emitEvent("open_settings", "");
}, []);
const setWindowAlwaysOnTop = useCallback(async (isPinned: boolean) => {
return platformAdapter.setAlwaysOnTop(isPinned);
}, []);
const getDataSourcesByServer = useCallback(
async (serverId: string): Promise<DataSource[]> => {
return platformAdapter.invokeBackend("get_datasources_by_server", {
id: serverId,
});
},
[]
);
const setupWindowFocusListener = useCallback(async (callback: () => void) => {
return platformAdapter.listenEvent("tauri://focus", callback);
}, []);
const checkScreenPermission = useCallback(async () => {
return platformAdapter.checkScreenRecordingPermission();
}, []);
const requestScreenPermission = useCallback(() => {
return platformAdapter.requestScreenRecordingPermission();
}, []);
const getScreenMonitors = useCallback(async () => {
return platformAdapter.getScreenshotableMonitors();
}, []);
const getScreenWindows = useCallback(async () => {
return platformAdapter.getScreenshotableWindows();
}, []);
const captureMonitorScreenshot = useCallback(async (id: number) => {
return platformAdapter.captureMonitorScreenshot(id);
}, []);
const captureWindowScreenshot = useCallback(async (id: number) => {
return platformAdapter.captureWindowScreenshot(id);
}, []);
const openFileDialog = useCallback(async (options: { multiple: boolean }) => {
return platformAdapter.openFileDialog(options);
}, []);
const getFileMetadata = useCallback(async (path: string) => {
return platformAdapter.getFileMetadata(path);
}, []);
const getFileIcon = useCallback(async (path: string, size: number) => {
return platformAdapter.getFileIcon(path, size);
}, []);
const checkUpdate = useCallback(async () => {
return platformAdapter.checkUpdate();
}, []);
const relaunchApp = useCallback(async () => {
return platformAdapter.relaunchApp();
}, []);
const defaultStartupWindow = useStartupStore((state) => {
return state.defaultStartupWindow;
});
const showCocoListenRef = useRef<(() => void) | undefined>();
useEffect(() => {
const setupShowCocoListener = async () => {
if (showCocoListenRef.current) {
showCocoListenRef.current();
showCocoListenRef.current = undefined;
}
try {
const unlisten = await platformAdapter.listenEvent("show-coco", () => {
const chatMode = localStorage.getItem("coco-chat-mode");
changeMode(
chatMode ? chatMode === "true" : defaultStartupWindow === "chatMode"
);
});
showCocoListenRef.current = unlisten;
} catch (error) {
console.error("Error setting up show-coco listener:", error);
}
};
setupShowCocoListener();
return () => {
if (showCocoListenRef.current) {
showCocoListenRef.current();
showCocoListenRef.current = undefined;
}
};
}, [defaultStartupWindow, changeMode]);
return (
<ErrorBoundary>
<div
data-tauri-drag-region
className={clsx(
"size-full m-auto overflow-hidden relative bg-no-repeat bg-cover bg-center",
[
isTransitioned
? "bg-chat_bg_light dark:bg-chat_bg_dark"
: "bg-search_bg_light dark:bg-search_bg_dark",
],
{
"rounded-xl": !isWin,
"border border-[#E6E6E6] dark:border-[#272626]": isLinux,
}
)}
>
<div
data-tauri-drag-region
className={`p-2 pb-0 absolute w-full flex items-center justify-center transition-all duration-500 ${
isTransitioned
? "top-[calc(100vh-90px)] h-[90px] border-t"
: "top-0 h-[90px] border-b"
} border-[#E6E6E6] dark:border-[#272626]`}
>
<InputBox
isChatMode={isChatMode}
inputValue={input}
onSend={handleSendMessage}
disabled={isTyping}
disabledChange={cancelChat}
changeMode={changeMode}
changeInput={setInput}
reconnect={reconnect}
isSearchActive={isSearchActive}
setIsSearchActive={toggleSearchActive}
isDeepThinkActive={isDeepThinkActive}
setIsDeepThinkActive={toggleDeepThinkActive}
getDataSourcesByServer={getDataSourcesByServer}
setupWindowFocusListener={setupWindowFocusListener}
checkScreenPermission={checkScreenPermission}
requestScreenPermission={requestScreenPermission}
getScreenMonitors={getScreenMonitors}
getScreenWindows={getScreenWindows}
captureMonitorScreenshot={captureMonitorScreenshot}
captureWindowScreenshot={captureWindowScreenshot}
openFileDialog={openFileDialog}
getFileMetadata={getFileMetadata}
getFileIcon={getFileIcon}
/>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-opacity duration-500 ${
isTransitioned ? "opacity-0 pointer-events-none" : "opacity-100"
} bottom-0 h-[calc(100vh-90px)] `}
>
<Suspense fallback={<LoadingFallback />}>
<Search
key="Search"
input={input}
isChatMode={isChatMode}
changeInput={setInput}
querySearch={querySearch}
queryDocuments={queryDocuments}
hideCoco={hideCoco}
openSetting={openSetting}
setWindowAlwaysOnTop={setWindowAlwaysOnTop}
/>
</Suspense>
</div>
<div
data-tauri-drag-region
className={`absolute w-full transition-all duration-500 select-auto ${
isTransitioned
? "top-0 opacity-100 pointer-events-auto"
: "-top-[506px] opacity-0 pointer-events-none"
} h-[calc(100vh-90px)]`}
>
{isTransitioned && isChatMode ? (
<Suspense fallback={<LoadingFallback />}>
<ChatAI
ref={chatAIRef}
key="ChatAI"
isTransitioned={isTransitioned}
changeInput={setInput}
isSearchActive={isSearchActive}
isDeepThinkActive={isDeepThinkActive}
getFileUrl={getFileUrl}
/>
</Suspense>
) : null}
</div>
<UpdateApp checkUpdate={checkUpdate} relaunchApp={relaunchApp} />
</div>
</ErrorBoundary>
);
}
export default memo(SearchChat);

24
src/pages/web/index.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { useCallback } from "react";
import SearchChat from "./SearchChat";
function WebApp() {
const querySearch = useCallback(async (input: string) => {
console.log(input);
}, []);
const queryDocuments = useCallback(
async (from: number, size: number, queryStrings: any) => {
console.log(from, size, queryStrings);
},
[]
);
return (
<div className="w-[680px] h-[590px]">
<SearchChat querySearch={querySearch} queryDocuments={queryDocuments} />
</div>
);
}
export default WebApp;

View File

@@ -0,0 +1,55 @@
export type AppState = {
isChatMode: boolean;
input: string;
isTransitioned: boolean;
isSearchActive: boolean;
isDeepThinkActive: boolean;
isTyping: boolean;
isLoading: boolean;
};
export type AppAction =
| { type: 'SET_CHAT_MODE'; payload: boolean }
| { type: 'SET_INPUT'; payload: string }
| { type: 'TOGGLE_SEARCH_ACTIVE' }
| { type: 'TOGGLE_DEEP_THINK_ACTIVE' }
| { type: 'SET_TYPING'; payload: boolean }
| { type: 'SET_LOADING'; payload: boolean };
const getCachedChatMode = (): boolean => {
try {
const cached = localStorage.getItem('coco-chat-mode');
return cached === 'true';
} catch {
return false;
}
};
export const initialAppState: AppState = {
isChatMode: getCachedChatMode(),
input: "",
isTransitioned: getCachedChatMode(),
isSearchActive: false,
isDeepThinkActive: false,
isTyping: false,
isLoading: false
};
export function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case 'SET_CHAT_MODE':
return { ...state, isChatMode: action.payload, isTransitioned: action.payload };
case 'SET_INPUT':
return { ...state, input: action.payload };
case 'TOGGLE_SEARCH_ACTIVE':
return { ...state, isSearchActive: !state.isSearchActive };
case 'TOGGLE_DEEP_THINK_ACTIVE':
return { ...state, isDeepThinkActive: !state.isDeepThinkActive };
case 'SET_TYPING':
return { ...state, isTyping: action.payload };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state;
}
}

View File

@@ -1,10 +1,11 @@
import { createBrowserRouter } from "react-router-dom";
import Layout from "./layout";
import ErrorPage from "@/error-page";
import ErrorPage from "@/pages/error/index";
import DesktopApp from "@/pages/main/index";
import SettingsPage from "@/pages/settings/index";
import ChatAI from "@/pages/chat/index";
import WebPage from "@/pages/web/index";
export const router = createBrowserRouter([
{
@@ -15,6 +16,7 @@ export const router = createBrowserRouter([
{ path: "/ui", element: <DesktopApp /> },
{ path: "/ui/settings", element: <SettingsPage /> },
{ path: "/ui/chat", element: <ChatAI /> },
{ path: "/web", element: <WebPage /> },
],
},
]);

View File

@@ -1,17 +1,13 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { listen } from "@tauri-apps/api/event";
import { useAsyncEffect, useEventListener, useMount } from "ahooks";
import { useAppStore } from "@/stores/appStore";
import useEscape from "@/hooks/useEscape";
import useSettingsWindow from "@/hooks/useSettingsWindow";
import { useAsyncEffect, useEventListener, useMount } from "ahooks";
import { useThemeStore } from "@/stores/themeStore";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { AppTheme } from "@/utils/tauri";
const appWindow = getCurrentWebviewWindow();
import platformAdapter from "@/utils/platformAdapter";
export default function Layout() {
const location = useLocation();
@@ -31,11 +27,11 @@ export default function Layout() {
}
useMount(async () => {
listen<AppTheme>("theme-changed", ({ payload }) => {
platformAdapter.listenThemeChanged((payload) => {
setTheme(payload);
});
appWindow.onThemeChanged(({ payload }) => {
platformAdapter.onThemeChanged(({ payload }) => {
if (activeTheme !== "auto") return;
setIsDark(payload === "dark");
@@ -43,11 +39,11 @@ export default function Layout() {
});
useAsyncEffect(async () => {
let nextTheme = activeTheme === "auto" ? null : activeTheme;
let nextTheme: any = activeTheme === "auto" ? null : activeTheme;
await appWindow.setTheme(nextTheme);
await platformAdapter.setWindowTheme(nextTheme);
nextTheme = nextTheme ?? (await appWindow.theme());
nextTheme = nextTheme ?? (await platformAdapter.getWindowTheme());
setIsDark(nextTheme === "dark");
}, [activeTheme]);
@@ -76,13 +72,19 @@ export default function Layout() {
i18n.changeLanguage(language);
}
const unlistenLanguageChange = listen("language-changed", (event: any) => {
const { language } = event.payload;
i18n.changeLanguage(language);
});
const setupLanguageListener = async () => {
const unlisten = await platformAdapter.listenEvent(
"language-changed",
(event) => {
i18n.changeLanguage(event.payload.language);
}
);
return unlisten;
};
const unlistenPromise = setupLanguageListener();
return () => {
unlistenLanguageChange.then((unlisten) => unlisten());
unlistenPromise.then(unlisten => unlisten());
};
}, []);

View File

@@ -1,8 +1,8 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { listen, emit } from "@tauri-apps/api/event";
import { AppEndpoint } from "@/utils/tauri";
import platformAdapter from "@/utils/platformAdapter";
const ENDPOINT_CHANGE_EVENT = "endpoint-changed";
@@ -51,6 +51,9 @@ export type IAppStore = {
showCocoShortcuts: string[];
setShowCocoShortcuts: (showCocoShortcuts: string[]) => void;
visible: boolean;
withVisibility: <T>(fn: () => Promise<T>) => Promise<T>;
};
export const useAppStore = create<IAppStore>()(
@@ -82,7 +85,7 @@ export const useAppStore = create<IAppStore>()(
endpoint_websocket,
});
await emit(ENDPOINT_CHANGE_EVENT, {
await platformAdapter.emitEvent(ENDPOINT_CHANGE_EVENT, {
endpoint,
endpoint_http,
endpoint_websocket,
@@ -93,7 +96,7 @@ export const useAppStore = create<IAppStore>()(
isPinned: false,
setIsPinned: (isPinned: boolean) => set({ isPinned }),
initializeListeners: () => {
listen(ENDPOINT_CHANGE_EVENT, (event: any) => {
platformAdapter.listenEvent(ENDPOINT_CHANGE_EVENT, (event: any) => {
const { endpoint, endpoint_http, endpoint_websocket } = event.payload;
set({ endpoint, endpoint_http, endpoint_websocket });
});
@@ -104,6 +107,16 @@ export const useAppStore = create<IAppStore>()(
return set({ showCocoShortcuts });
},
visible: false,
withVisibility: async <T>(fn: () => Promise<T>) => {
set({ visible: true });
const result = await fn();
set({ visible: false });
return result;
},
}),
{
name: "app-store",

View File

@@ -1,8 +1,9 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { listen, emit } from '@tauri-apps/api/event';
import { produce } from 'immer'
import platformAdapter from "@/utils/platformAdapter";
const AUTH_CHANGE_EVENT = 'auth-changed';
const USERINFO_CHANGE_EVENT = 'userInfo-changed';
@@ -47,7 +48,7 @@ export const useAuthStore = create<IAuthStore>()(
})
);
await emit(AUTH_CHANGE_EVENT, {
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
auth: {
[key]: auth
}
@@ -60,7 +61,7 @@ export const useAuthStore = create<IAuthStore>()(
})
);
await emit(AUTH_CHANGE_EVENT, {
await platformAdapter.emitEvent(AUTH_CHANGE_EVENT, {
auth: {
[key]: undefined
}
@@ -73,19 +74,19 @@ export const useAuthStore = create<IAuthStore>()(
})
);
await emit(USERINFO_CHANGE_EVENT, {
await platformAdapter.emitEvent(USERINFO_CHANGE_EVENT, {
userInfo: {
[key]: userInfo
}
});
},
initializeListeners: () => {
listen(AUTH_CHANGE_EVENT, (event: any) => {
platformAdapter.listenEvent(AUTH_CHANGE_EVENT, (event: any) => {
const { auth } = event.payload;
set({ auth });
});
listen(USERINFO_CHANGE_EVENT, (event: any) => {
platformAdapter.listenEvent(USERINFO_CHANGE_EVENT, (event: any) => {
const { userInfo } = event.payload;
set({ userInfo });
});

View File

@@ -9,6 +9,8 @@ interface UploadFile extends Metadata {
id: string;
path: string;
icon: string;
uploaded?: boolean;
attachmentId?: string;
}
export type IChatStore = {
@@ -46,9 +48,7 @@ export const useChatStore = create<IChatStore>()(
{
name: "chat-state",
// storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
curChatEnd: state.curChatEnd,
connected: state.connected,
partialize: (_state) => ({
}),
}
)

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { produce } from 'immer'
import { produce } from "immer";
import { listen, emit } from "@tauri-apps/api/event";
const CONNECTOR_CHANGE_EVENT = "connector_data_change";
@@ -15,10 +15,14 @@ export type IConnectStore = {
setServerList: (servers: []) => void;
currentService: any;
setCurrentService: (service: any) => void;
connector_data: keyArrayObject,
setConnectorData: (connector_data: any[], key: string) => void,
datasourceData: keyArrayObject,
setDatasourceData: (datasourceData: any[], key: string) => void,
connector_data: keyArrayObject;
setConnectorData: (connector_data: any[], key: string) => void;
datasourceData: keyArrayObject;
setDatasourceData: (datasourceData: any[], key: string) => void;
connectionTimeout: number;
setConnectionTimeout: (connectionTimeout: number) => void;
currentSessionId?: string;
setCurrentSessionId: (currentSessionId?: string) => void;
};
export const useConnectStore = create<IConnectStore>()(
@@ -26,23 +30,27 @@ export const useConnectStore = create<IConnectStore>()(
(set) => ({
serverList: [],
setServerList: (serverList: []) => {
console.log("set serverList:", serverList)
set(produce((draft) => {
draft.serverList = serverList;
}))
console.log("set serverList:", serverList);
set(
produce((draft) => {
draft.serverList = serverList;
})
);
},
currentService: "default_coco_server",
setCurrentService: (server: any) => {
console.log("set default server:", server)
set(produce((draft) => {
draft.currentService = server;
}))
console.log("set default server:", server);
set(
produce((draft) => {
draft.currentService = server;
})
);
},
connector_data: {},
setConnectorData: async (connector_data: any[], key: string) => {
set(
produce((draft) => {
draft.connector_data[key] = connector_data
draft.connector_data[key] = connector_data;
})
);
await emit(CONNECTOR_CHANGE_EVENT, {
@@ -53,7 +61,7 @@ export const useConnectStore = create<IConnectStore>()(
setDatasourceData: async (datasourceData: any[], key: string) => {
set(
produce((draft) => {
draft.datasourceData[key] = datasourceData
draft.datasourceData[key] = datasourceData;
})
);
await emit(DATASOURCE_CHANGE_EVENT, {
@@ -70,6 +78,13 @@ export const useConnectStore = create<IConnectStore>()(
set({ datasourceData });
});
},
connectionTimeout: 120,
setConnectionTimeout: (connectionTimeout: number) => {
return set(() => ({ connectionTimeout }));
},
setCurrentSessionId(currentSessionId) {
return set(() => ({ currentSessionId }));
},
}),
{
name: "connect-store",
@@ -77,7 +92,8 @@ export const useConnectStore = create<IConnectStore>()(
currentService: state.currentService,
connector_data: state.connector_data,
datasourceData: state.datasourceData,
connectionTimeout: state.connectionTimeout,
}),
}
)
);
);

View File

@@ -0,0 +1,60 @@
import { isMac } from "@/utils/platform";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type ModifierKey = "meta" | "ctrl" | "alt";
export type IShortcutsStore = {
modifierKey: ModifierKey;
setModifierKey: (modifierKey: ModifierKey) => void;
modifierKeyPressed: boolean;
setModifierKeyPressed: (modifierKeyPressed: boolean) => void;
modeSwitch: string;
setModeSwitch: (modeSwitch: string) => void;
returnToInput: string;
setReturnToInput: (returnToInput: string) => void;
voiceInput: string;
setVoiceInput: (voiceInput: string) => void;
addImage: string;
setAddImage: (addImage: string) => void;
selectLlmModel: string;
setSelectLlmModel: (selectLlmModel: string) => void;
addFile: string;
setAddFile: (addFile: string) => void;
};
export const useShortcutsStore = create<IShortcutsStore>()(
persist(
(set) => ({
modifierKey: isMac ? "meta" : "ctrl",
setModifierKey: (modifierKey: ModifierKey) => set({ modifierKey }),
modifierKeyPressed: false,
setModifierKeyPressed: (modifierKeyPressed: boolean) =>
set({ modifierKeyPressed }),
modeSwitch: "T",
setModeSwitch: (modeSwitch: string) => set({ modeSwitch }),
returnToInput: "I",
setReturnToInput: (returnToInput: string) => set({ returnToInput }),
voiceInput: "N",
setVoiceInput: (voiceInput: string) => set({ voiceInput }),
addImage: "G",
setAddImage: (addImage: string) => set({ addImage }),
selectLlmModel: "O",
setSelectLlmModel: (selectLlmModel: string) => set({ selectLlmModel }),
addFile: "U",
setAddFile: (addFile: string) => set({ addFile }),
}),
{
name: "shortcuts-store",
partialize: (state) => ({
modifierKey: state.modifierKey,
modeSwitch: state.modeSwitch,
returnToInput: state.returnToInput,
voiceInput: state.voiceInput,
addImage: state.addImage,
selectLlmModel: state.selectLlmModel,
addFile: state.addFile,
}),
}
)
);

View File

@@ -0,0 +1,48 @@
import { create } from "zustand";
import { persist, subscribeWithSelector } from "zustand/middleware";
type DefaultStartupWindow = "searchMode" | "chatMode";
type DefaultContentForSearchWindow = "systemDefault";
type DefaultContentForChatWindow = "newChat" | "oldChat";
export type IStartupStore = {
defaultStartupWindow: DefaultStartupWindow;
setDefaultStartupWindow: (defaultStartupWindow: DefaultStartupWindow) => void;
defaultContentForSearchWindow: DefaultContentForSearchWindow;
setDefaultContentForSearchWindow: (
defaultContentForSearchWindow: DefaultContentForSearchWindow
) => void;
defaultContentForChatWindow: DefaultContentForChatWindow;
setDefaultContentForChatWindow: (
defaultContentForChatWindow: DefaultContentForChatWindow
) => void;
};
export const useStartupStore = create<IStartupStore>()(
subscribeWithSelector(
persist(
(set) => ({
defaultStartupWindow: "searchMode",
setDefaultStartupWindow: (defaultStartupWindow) => {
return set({ defaultStartupWindow });
},
defaultContentForSearchWindow: "systemDefault",
setDefaultContentForSearchWindow: (defaultContentForSearchWindow) => {
return set({ defaultContentForSearchWindow });
},
defaultContentForChatWindow: "oldChat",
setDefaultContentForChatWindow: (defaultContentForChatWindow) => {
return set({ defaultContentForChatWindow });
},
}),
{
name: "startup-store",
partialize: (state) => ({
defaultStartupWindow: state.defaultStartupWindow,
defaultContentForSearchWindow: state.defaultContentForSearchWindow,
defaultContentForChatWindow: state.defaultContentForChatWindow,
}),
}
)
)
);

114
src/types/commands.ts Normal file
View File

@@ -0,0 +1,114 @@
export interface ServerTokenResponse {
access_token?: string;
}
interface Provider {
name: string;
icon: string;
website: string;
eula: string;
privacy_policy: string;
banner: string;
description: string;
}
interface Version {
number: string;
}
interface Sso {
url: string;
}
interface AuthProvider {
sso: Sso;
}
interface MinimalClientVersion {
number: string;
}
type Status = "green" | "yellow" | "red";
interface Health {
services?: Record<string, Status>;
status: Status;
}
interface Preferences {
theme: string;
language: string;
}
interface UserProfile {
name: string;
email: string;
avatar: string;
preferences: Preferences;
}
export interface Server {
id: string;
builtin: boolean;
name: string;
endpoint: string;
provider: Provider;
version: Version;
minimal_client_version?: MinimalClientVersion;
updated: string;
enabled: boolean;
public: boolean;
available: boolean;
health?: Health;
profile?: UserProfile;
auth_provider: AuthProvider;
priority: number;
}
interface ConnectorAssets {
icons?: Record<string, string>;
}
export interface Connector {
id: string;
created?: string;
updated?: string;
name: string;
description?: string;
category?: string;
icon?: string;
tags?: string[];
url?: string;
assets?: ConnectorAssets;
}
interface ConnectorConfig {
id?: string;
config?: Record<string, unknown>;
}
export interface DataSource {
id: string;
icon?: string;
created?: string;
updated?: string;
type?: string;
name?: string;
connector?: ConnectorConfig;
connector_info?: Connector;
}
interface Source {
id: string;
created: string;
updated: string;
status: string;
}
export interface GetResponse {
_id: string;
_source: Source;
result: string;
payload?: Record<string, unknown>;
}

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from "react";
import { invoke, isTauri } from "@tauri-apps/api/core";
import { isTauri } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-shell";
import { hide_coco } from "@/commands"
// 1
export async function copyToClipboard(text: string) {
try {
@@ -66,7 +68,7 @@ export const OpenURLWithBrowser = async (url: string) => {
if (isTauri()) {
try {
await open(url);
await invoke("hide_coco");
await hide_coco();
console.log("URL opened in default browser");
} catch (error) {
console.error("Failed to open URL:", error);

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