feat: add togglePin & switch server (#166)

* feat: add togglePin

* proxy websocket poc

* feat: switch server

* merge: merge switch ws server

* feat: add switch server

---------

Co-authored-by: medcl <m@medcl.net>
This commit is contained in:
BiggerRain
2025-02-21 18:57:32 +08:00
committed by GitHub
parent ca9adb515b
commit a383ec3273
21 changed files with 925 additions and 233 deletions

319
src-tauri/Cargo.lock generated
View File

@@ -74,6 +74,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
[[package]]
name = "anstyle-parse"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
dependencies = [
"anstyle",
"once_cell",
"windows-sys 0.59.0",
]
[[package]]
name = "anyhow"
version = "1.0.89"
@@ -660,9 +710,13 @@ dependencies = [
"async-trait",
"base64 0.13.1",
"dirs 5.0.1",
"env_logger",
"futures",
"futures-util",
"fuzzy_prefix_search",
"hostname",
"http 1.1.0",
"hyper 0.14.32",
"lazy_static",
"log",
"notify",
@@ -693,6 +747,10 @@ dependencies = [
"tauri-plugin-websocket",
"thiserror 1.0.64",
"tokio",
"tokio-native-tls",
"tokio-tungstenite 0.20.1",
"tungstenite 0.24.0",
"url",
"walkdir",
]
@@ -726,6 +784,12 @@ dependencies = [
"objc",
]
[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "combine"
version = "4.6.7"
@@ -1296,6 +1360,29 @@ dependencies = [
"syn 2.0.90",
]
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcaee3d8e3cfc3fd92428d477bc97fc29ec8716d180c0d74c643bb26166660e0"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"humantime",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.1"
@@ -1949,7 +2036,7 @@ dependencies = [
"fnv",
"futures-core",
"futures-sink",
"http",
"http 1.1.0",
"indexmap 2.6.0",
"slab",
"tokio",
@@ -2034,6 +2121,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "http"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
dependencies = [
"bytes",
"fnv",
"itoa 1.0.11",
]
[[package]]
name = "http"
version = "1.1.0"
@@ -2045,6 +2143,17 @@ dependencies = [
"itoa 1.0.11",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http 0.2.12",
"pin-project-lite",
]
[[package]]
name = "http-body"
version = "1.0.1"
@@ -2052,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
"http 1.1.0",
]
[[package]]
@@ -2063,8 +2172,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
dependencies = [
"bytes",
"futures-util",
"http",
"http-body",
"http 1.1.0",
"http-body 1.0.1",
"pin-project-lite",
]
@@ -2080,6 +2189,40 @@ version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
name = "hyper"
version = "0.14.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
"httpdate",
"itoa 1.0.11",
"pin-project-lite",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper"
version = "1.4.1"
@@ -2090,8 +2233,8 @@ dependencies = [
"futures-channel",
"futures-util",
"h2",
"http",
"http-body",
"http 1.1.0",
"http-body 1.0.1",
"httparse",
"itoa 1.0.11",
"pin-project-lite",
@@ -2107,15 +2250,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333"
dependencies = [
"futures-util",
"http",
"hyper",
"http 1.1.0",
"hyper 1.4.1",
"hyper-util",
"rustls",
"rustls 0.23.15",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-rustls 0.26.0",
"tower-service",
"webpki-roots",
"webpki-roots 0.26.6",
]
[[package]]
@@ -2126,7 +2269,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper",
"hyper 1.4.1",
"hyper-util",
"native-tls",
"tokio",
@@ -2143,9 +2286,9 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"http 1.1.0",
"http-body 1.0.1",
"hyper 1.4.1",
"pin-project-lite",
"socket2 0.5.7",
"tokio",
@@ -2330,6 +2473,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itoa"
version = "0.4.8"
@@ -3653,7 +3802,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"rustls 0.23.15",
"socket2 0.5.7",
"thiserror 1.0.64",
"tokio",
@@ -3670,7 +3819,7 @@ dependencies = [
"rand 0.8.5",
"ring",
"rustc-hash",
"rustls",
"rustls 0.23.15",
"slab",
"thiserror 1.0.64",
"tinyvec",
@@ -3860,10 +4009,10 @@ dependencies = [
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"http 1.1.0",
"http-body 1.0.1",
"http-body-util",
"hyper",
"hyper 1.4.1",
"hyper-rustls",
"hyper-tls",
"hyper-util",
@@ -3876,7 +4025,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls 0.23.15",
"rustls-pemfile",
"rustls-pki-types",
"serde",
@@ -3886,7 +4035,7 @@ dependencies = [
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-rustls 0.26.0",
"tokio-util",
"tower",
"tower-service",
@@ -3895,7 +4044,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"webpki-roots 0.26.6",
"windows-registry 0.2.0",
]
@@ -3996,6 +4145,18 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.21.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
dependencies = [
"log",
"ring",
"rustls-webpki 0.101.7",
"sct",
]
[[package]]
name = "rustls"
version = "0.23.15"
@@ -4005,7 +4166,7 @@ dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"rustls-webpki 0.102.8",
"subtle",
"zeroize",
]
@@ -4025,6 +4186,16 @@ version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b"
[[package]]
name = "rustls-webpki"
version = "0.101.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
@@ -4099,6 +4270,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sct"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
dependencies = [
"ring",
"untrusted",
]
[[package]]
name = "security-framework"
version = "2.11.1"
@@ -4665,7 +4846,7 @@ dependencies = [
"glob",
"gtk",
"heck 0.5.0",
"http",
"http 1.1.0",
"http-range",
"image",
"jni",
@@ -4909,7 +5090,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c752aee1b00ec3c4d4f440095995d9bd2c640b478f2067d1fba388900b82eb96"
dependencies = [
"data-url",
"http",
"http 1.1.0",
"regex",
"reqwest",
"schemars",
@@ -5036,7 +5217,7 @@ dependencies = [
"dirs 6.0.0",
"flate2",
"futures-util",
"http",
"http 1.1.0",
"infer",
"minisign-verify",
"osakit",
@@ -5064,7 +5245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14055b796521e0facf6582256dea9322453595917851082610c557293862c966"
dependencies = [
"futures-util",
"http",
"http 1.1.0",
"log",
"rand 0.8.5",
"serde",
@@ -5073,7 +5254,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.6",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.24.0",
]
[[package]]
@@ -5084,7 +5265,7 @@ checksum = "cce18d43f80d4aba3aa8a0c953bbe835f3d0f2370aca75e8dbb14bd4bab27958"
dependencies = [
"dpi",
"gtk",
"http",
"http 1.1.0",
"jni",
"raw-window-handle",
"serde",
@@ -5102,7 +5283,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f442a38863e10129ffe2cec7bd09c2dcf8a098a3a27801a476a304d5bb991d2"
dependencies = [
"gtk",
"http",
"http 1.1.0",
"jni",
"log",
"objc2",
@@ -5133,7 +5314,7 @@ dependencies = [
"dunce",
"glob",
"html5ever",
"http",
"http 1.1.0",
"infer",
"json-patch",
"kuchikiki",
@@ -5313,6 +5494,7 @@ dependencies = [
"bytes",
"libc",
"mio 1.0.2",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.7",
@@ -5342,17 +5524,42 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.24.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
dependencies = [
"rustls 0.21.12",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4"
dependencies = [
"rustls",
"rustls 0.23.15",
"rustls-pki-types",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c"
dependencies = [
"futures-util",
"log",
"rustls 0.21.12",
"tokio",
"tokio-rustls 0.24.1",
"tungstenite 0.20.1",
"webpki-roots 0.25.4",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
@@ -5361,12 +5568,12 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls 0.23.15",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"webpki-roots",
"tokio-rustls 0.26.0",
"tungstenite 0.24.0",
"webpki-roots 0.26.6",
]
[[package]]
@@ -5543,6 +5750,26 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http 0.2.12",
"httparse",
"log",
"rand 0.8.5",
"rustls 0.21.12",
"sha1",
"thiserror 1.0.64",
"url",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
@@ -5552,11 +5779,11 @@ dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"http 1.1.0",
"httparse",
"log",
"rand 0.8.5",
"rustls",
"rustls 0.23.15",
"rustls-pki-types",
"sha1",
"thiserror 1.0.64",
@@ -5690,6 +5917,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.10.0"
@@ -5969,6 +6202,12 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "0.26.6"
@@ -6472,7 +6711,7 @@ dependencies = [
"gdkx11",
"gtk",
"html5ever",
"http",
"http 1.1.0",
"javascriptcore-rs",
"jni",
"kuchikiki",

View File

@@ -39,12 +39,14 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-drag = "2"
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"
futures = "0.3.31"
ordered-float = { version = "4.6.0", default-features = false }
lazy_static = "1.5.0"
log = "0.4.22"
tokio = "1.40.0"
once_cell = "1.20.2"
notify = "5.0"
async-trait = "0.1.82"
@@ -55,6 +57,13 @@ plist = "1.7"
base64 = "0.13"
walkdir = "2"
fuzzy_prefix_search = "0.2"
log = "0.4"
futures-util = "0.3.31"
url = "2.5.2"
http = "1.1.0"
tungstenite = "0.24.0"
env_logger = "0.11.5"
[target."cfg(target_os = \"macos\")".dependencies]
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }

View File

@@ -31,6 +31,7 @@
"core:window:allow-set-size",
"core:window:allow-get-all-windows",
"core:window:allow-set-focus",
"core:window:allow-set-always-on-top",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",

View File

@@ -52,7 +52,9 @@ struct Payload {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut ctx = tauri::generate_context!();
// Initialize logger
env_logger::init();
let mut app_builder = tauri::Builder::default();
#[cfg(desktop)]
@@ -63,7 +65,8 @@ pub fn run() {
}));
}
app_builder = app_builder.plugin(tauri_plugin_http::init())
app_builder = app_builder
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_autostart::init(
MacosLauncher::AppleScript,
@@ -102,12 +105,15 @@ pub fn run() {
// server::get_coco_servers_health_info,
// server::get_user_profiles,
// server::get_coco_server_datasources,
// server::get_coco_server_connectors
// server::get_coco_server_connectors,
server::websocket::connect_to_server,
server::websocket::disconnect,
])
.setup(|app| {
let registry = SearchSourceRegistry::default();
app.manage(registry); // Store registry in Tauri's app state
app.manage(server::websocket::WebSocketManager::default());
// Get app handle
let app_handle = app.handle().clone();

View File

@@ -17,3 +17,4 @@ pub mod datasource;
pub mod http_client;
pub mod profile;
pub mod search;
pub mod websocket;

View File

@@ -0,0 +1,175 @@
use crate::server::servers::{get_server_by_id, get_server_token};
use futures_util::{SinkExt, StreamExt};
use http::{HeaderMap, HeaderName, HeaderValue};
use std::sync::Arc;
use tauri::Emitter;
use tokio::net::TcpStream;
use tokio::sync::{mpsc, Mutex};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::Error;
use tokio_tungstenite::{connect_async, tungstenite::protocol::Message, MaybeTlsStream, WebSocketStream};
use tungstenite::handshake::client::generate_key;
#[derive(Default)]
pub struct WebSocketManager {
ws_connection: Arc<Mutex<Option<WebSocketStream<MaybeTlsStream<TcpStream>>>>>,
cancel_tx: Arc<Mutex<Option<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))?;
// 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,
state: tauri::State<'_, WebSocketManager>,
app_handle: tauri::AppHandle,
) -> Result<(), String> {
dbg!("Connecting to server",id.as_str());
// Disconnect any existing connection first
disconnect(state.clone()).await?;
dbg!("Disconnected from previous server",id.as_str());
// Retrieve server details
let server = get_server_by_id(id.as_str())
.ok_or_else(|| format!("Server with ID {} not found", id))?;
let mut endpoint = convert_to_websocket(server.endpoint.as_str())?;
dbg!("Server endpoint",endpoint.as_str());
// Retrieve the token for the server (token is optional)
let token = get_server_token(id.as_str()).map(|t| t.access_token.clone());
dbg!("Server token",token.as_ref().unwrap_or(&"".to_string()).as_str());
// 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))?;
dbg!("WebSocket request");
// 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(),
);
dbg!("WebSocket headers",request.headers().iter().map(|(k, v)| format!("{}: {}", k.as_str(), v.to_str().unwrap())).collect::<Vec<String>>().join("\n"));
// If a token exists, add it to the headers
if let Some(token) = token {
request.headers_mut().insert("X-API-TOKEN", token.parse().unwrap());
}
dbg!("WebSocket headers with token",request.headers().iter().map(|(k, v)| format!("{}: {}", k.as_str(), v.to_str().unwrap())).collect::<Vec<String>>().join("\n"));
// Establish the WebSocket connection
dbg!(&request);
let (mut ws_remote, _) = connect_async(request).await
.map_err(|e| {
dbg!("WebSocket connection error",&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),
}
})?;
dbg!("Connected to server1234",id.as_str());
// Create cancellation channel
let (cancel_tx, mut cancel_rx) = mpsc::channel(1);
dbg!("try to lock Connected to server",id.as_str());
// Store connection and cancellation sender
*state.ws_connection.lock().await = Some(ws_remote);
*state.cancel_tx.lock().await = Some(cancel_tx);
dbg!("locked Connected to server",id.as_str());
// Spawn listener task with cancellation
let app_handle_clone = app_handle.clone();
let connection_clone = state.ws_connection.clone();
tokio::spawn(async move {
let mut connection = connection_clone.lock().await;
if let Some(ws) = connection.as_mut() {
loop {
dbg!("try to select Connected to server",id.as_str());
tokio::select! {
msg = ws.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let _ = app_handle_clone.emit("ws-message", text);
},
Some(Err(_)) | None => break,
_ => continue,
}
}
_ = cancel_rx.recv() => {
break;
}
}
}
}
});
dbg!("END Connected to server");
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() {
let _ = ws.close(None).await;
}
Ok(())
}

View File

@@ -23,7 +23,6 @@
"maximizable": false,
"skipTaskbar": true,
"resizable": false,
"alwaysOnTop": true,
"acceptFirstMouse": true,
"shadow": true,
"transparent": true,

View File

@@ -39,7 +39,10 @@ export const tauriFetch = async <T = any>({
const addLog = useLogStore.getState().addLog;
try {
console.log("baseURL", baseURL)
const appStore = JSON.parse(localStorage.getItem("app-store") || "{}");
console.log("baseURL", appStore.state?.endpoint_http)
baseURL = appStore.state?.endpoint_http || baseURL;
const authStore = JSON.parse(localStorage.getItem("auth-store") || "{}")
const auth = authStore?.state?.auth
@@ -58,7 +61,8 @@ export const tauriFetch = async <T = any>({
headers["Content-Type"] = "application/json";
}
const res: any = await invoke("get_server_token", {id: "default_coco_server"});
const server_id = appStore.state?.activeServer?.id || "default_coco_server"
const res: any = await invoke("get_server_token", {id: server_id});
headers["X-API-TOKEN"] = headers["X-API-TOKEN"] || res?.access_token || undefined;

View File

@@ -11,15 +11,17 @@ import {
import { isTauri } from "@tauri-apps/api/core";
import { useTranslation } from "react-i18next";
import { debounce } from "lodash-es";
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { ChatMessage } from "./ChatMessage";
import type { Chat } from "./types";
import { tauriFetch } from "@/api/tauriFetchClient";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useChatStore } from "@/stores/chatStore";
import { useWindows } from "@/hooks/useWindows";
import { clientEnv } from "@/utils/env";
import { ChatHeader } from "./ChatHeader";
import { useAppStore } from "@/stores/appStore";
interface ChatAIProps {
isTransitioned: boolean;
isSearchActive?: boolean;
@@ -50,7 +52,10 @@ const ChatAI = memo(
},
ref
) => {
if (!isTransitioned) return null;
const { t } = useTranslation();
useImperativeHandle(ref, () => ({
init: init,
cancelChat: cancelChat,
@@ -61,7 +66,9 @@ const ChatAI = memo(
const { createWin } = useWindows();
const { curChatEnd, setCurChatEnd, setConnected } = useChatStore();
const { curChatEnd, setCurChatEnd, connected, setConnected, messages, setMessages } =
useChatStore();
const activeServer = useAppStore((state) => state.activeServer);
const [activeChat, setActiveChat] = useState<Chat>();
const [isTyping, setIsTyping] = useState(false);
@@ -85,56 +92,75 @@ const ChatAI = memo(
setCurMessage((prev) => prev + chunk);
}, []);
const reconnect = async () => {
if (!activeServer?.id) return;
try {
await invoke("connect_to_server", { id: activeServer?.id });
setConnected(true);
} catch (error) {
console.error("Failed to connect:", error);
}
};
const messageTimeoutRef = useRef<NodeJS.Timeout>();
const dealMsg = useCallback((msg: string) => {
// console.log("msg:", msg);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
const dealMsg = useCallback(
(msg: string) => {
// console.log("msg:", msg);
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
websocketIdRef.current = array[2];
return "";
} else if (msg.includes("PRIVATE")) {
messageTimeoutRef.current = setTimeout(() => {
if (!curChatEnd && isTyping) {
console.log("AI response timeout");
setTimedoutShow(true);
cancelChat();
}
}, 30000);
if (msg.includes("assistant finished output")) {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
// console.log("AI finished output");
simulateAssistantResponse();
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
// console.log("cleanedData", cleanedData);
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk);
setMessages((prev) => prev + chunkData.message_chunk);
return chunkData.message_chunk;
if (msg.includes("websocket-session-id")) {
const array = msg.split(" ");
websocketIdRef.current = array[2];
return "";
} else if (msg.includes("PRIVATE")) {
messageTimeoutRef.current = setTimeout(() => {
if (!curChatEnd && isTyping) {
console.log("AI response timeout");
setTimedoutShow(true);
cancelChat();
}
}, 30000);
if (msg.includes("assistant finished output")) {
if (messageTimeoutRef.current) {
clearTimeout(messageTimeoutRef.current);
}
console.log("AI finished output");
setCurChatEnd(true);
} else {
const cleanedData = msg.replace(/^PRIVATE /, "");
try {
// console.log("cleanedData", cleanedData);
const chunkData = JSON.parse(cleanedData);
if (chunkData.reply_to_message === curIdRef.current) {
handleMessageChunk(chunkData.message_chunk);
return chunkData.message_chunk;
}
} catch (error) {
console.error("parse error:", error);
}
} catch (error) {
console.error("parse error:", error);
}
}
}
}, [curChatEnd, isTyping]);
const { messages, setMessages, connected, reconnect } = useWebSocket(
clientEnv.COCO_WEBSOCKET_URL,
dealMsg
},
[curChatEnd, isTyping]
);
useEffect(() => {
const unlisten = listen("ws-message", (event) => {
const data = dealMsg(String(event.payload));
if (data) {
setMessages((prev) => prev + data);
}
});
return () => {
unlisten.then((fn) => fn());
};
}, []);
const assistantMessage = useMemo(() => {
if (!activeChat?._id || (!curMessage && !messages)) return null;
return {
@@ -153,11 +179,11 @@ const ChatAI = memo(
messages: [...(activeChat.messages || []), assistantMessage],
};
}, [activeChat, assistantMessage]);
const simulateAssistantResponse = useCallback(() => {
if (!updatedChat) return;
// console.log("updatedChat:", updatedChat);
console.log("updatedChat:", updatedChat);
setActiveChat(updatedChat);
setMessages("");
setCurMessage("");
@@ -170,10 +196,6 @@ const ChatAI = memo(
}
}, [curChatEnd]);
useEffect(() => {
setConnected(connected);
}, [connected]);
const scrollToBottom = useCallback(
debounce(() => {
messagesEndRef.current?.scrollIntoView({
@@ -216,6 +238,7 @@ const ChatAI = memo(
const handleSendMessage = useCallback(
async (content: string, newChat?: Chat) => {
console.log("11111111", isSearchActive, isDeepThinkActive);
newChat = newChat || activeChat;
if (!newChat?._id || !content) return;
setTimedoutShow(false);
@@ -240,7 +263,7 @@ const ChatAI = memo(
};
changeInput && changeInput("");
// console.log("updatedChat2", updatedChat);
console.log("updatedChat2", updatedChat);
setActiveChat(updatedChat);
setIsTyping(true);
setCurChatEnd(false);
@@ -248,7 +271,7 @@ const ChatAI = memo(
console.error("Failed to fetch user data:", error);
}
},
[activeChat?._id, isSearchActive, isDeepThinkActive]
[activeChat, isSearchActive, isDeepThinkActive]
);
const chatClose = async () => {
@@ -318,8 +341,6 @@ const ChatAI = memo(
};
}, []);
if (!isTransitioned) return null;
return (
<div
data-tauri-drag-region

View File

@@ -1,13 +1,35 @@
import { MessageSquarePlus, PanelLeft, Pin, MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { Listbox, Popover } from "@headlessui/react";
import {
MessageSquarePlus,
PictureInPicture2,
Pin,
PinOff,
MoreHorizontal,
ChevronDownIcon,
Settings,
RefreshCw,
Check,
PanelRightClose,
} from "lucide-react";
import { useState, useEffect } from "react";
import {
Menu,
MenuButton,
MenuItem,
MenuItems,
Popover,
PopoverButton,
PopoverPanel,
} from "@headlessui/react";
import { useTranslation } from "react-i18next";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import logoImg from "@/assets/icon.svg";
import { useAppStore, IServer } from "@/stores/appStore";
import { useChatStore } from "@/stores/chatStore";
interface Server {
id: string;
name: string;
status: 'online' | 'offline';
assistantCount: number;
}
interface ChatHeaderProps {
onCreateNewChat: () => void;
@@ -15,64 +37,131 @@ interface ChatHeaderProps {
}
export function ChatHeader({ onCreateNewChat, onOpenChatAI }: ChatHeaderProps) {
const [isPinned, setIsPinned] = useState(false);
const [showAI] = useState(false);
const [servers] = useState<Server[]>([
{ id: '1', name: 'Coco Cloud', status: 'online', assistantCount: 3 },
{ id: '2', name: 'Searchkit', status: 'online', assistantCount: 3 },
{ id: '3', name: 'INFINI Labs', status: 'online', assistantCount: 2 },
{ id: '4', name: 'Test server', status: 'offline', assistantCount: 1 },
]);
const [selectedServer, setSelectedServer] = useState(servers[0]);
const { t } = useTranslation();
const setEndpoint = useAppStore((state) => state.setEndpoint);
const isPinned = useAppStore((state) => state.isPinned);
const setIsPinned = useAppStore((state) => state.setIsPinned);
const { setConnected, setMessages } = useChatStore();
const [serverList, setServerList] = useState<IServer[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const activeServer = useAppStore((state) => state.activeServer);
const setActiveServer = useAppStore((state) => state.setActiveServer);
const fetchServers = async (resetSelection: boolean) => {
invoke("list_coco_servers")
.then((res: any) => {
setServerList(res);
if (resetSelection && res.length > 0) {
setActiveServer(res[0]);
setEndpoint(res[0].endpoint);
switchServer(res[0]);
} else {
console.warn("Service list is empty or last item has no id");
}
})
.catch((err: any) => {
console.error(err);
});
};
useEffect(() => {
fetchServers(true);
}, []);
const disconnect = async () => {
console.log("disconnecting");
try {
await invoke("disconnect");
setConnected(false);
setActiveServer(null);
console.log("disconnected");
} catch (error) {
console.error("Failed to disconnect:", error);
}
};
const connect = async (server: IServer) => {
try {
await invoke("connect_to_server", { id: server.id });
setActiveServer(server);
setEndpoint(server.endpoint);
setConnected(true);
setMessages(""); // Clear previous messages
} catch (error) {
console.error("Failed to connect:", error);
}
};
const switchServer = async (server: IServer) => {
try {
await disconnect();
await connect(server);
} catch (error) {
console.error("switchServer:", error);
}
};
const togglePin = async () => {
try {
const newPinned = !isPinned;
await getCurrentWindow().setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
const openSettings = async () => {
emit("open_settings", "connect");
};
const openHistList = async () => {};
return (
<header className="flex items-center justify-between py-2 px-3" data-tauri-drag-region>
<header
className="flex items-center justify-between py-2 px-3"
data-tauri-drag-region
>
<div className="flex items-center gap-2">
<button
onClick={onOpenChatAI}
onClick={openHistList}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
>
<PanelLeft className="h-4 w-4" />
<PanelRightClose className="h-4 w-4" />
</button>
{showAI ? <Listbox value={selectedServer} onChange={setSelectedServer}>
<div className="relative">
<Listbox.Button className="relative w-48 h-8 px-3 py-1 text-left bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<img src="/path-to-server-icon.png" className="w-4 h-4 rounded-full" />
<span className="block truncate">{selectedServer.name}</span>
</div>
</Listbox.Button>
<Listbox.Options className="absolute w-full mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
{servers.map((server) => (
<Listbox.Option
key={server.id}
value={server}
className={({ active }) =>
`relative cursor-pointer select-none py-2 px-3 ${
active ? 'bg-gray-100 dark:bg-gray-700' : ''
}`
}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<img src="/path-to-server-icon.png" className="w-4 h-4 rounded-full" />
<span>{server.name}</span>
</div>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
server.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
}`} />
<span className="text-xs text-gray-500">
AI Assistant: {server.assistantCount}
</span>
</div>
</div>
</Listbox.Option>
))}
</Listbox.Options>
</div>
</Listbox> : null}
<Menu>
<MenuButton className="flex items-center gap-1 rounded-full bg-white dark:bg-[#202126] p-1 text-sm/6 font-semibold text-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none">
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
<ChevronDownIcon className="size-4 text-gray-500 dark:text-gray-400" />
</MenuButton>
<MenuItems
transition
anchor="bottom end"
className="w-28 origin-top-right rounded-xl bg-white dark:bg-[#202126] p-1 text-sm/6 text-gray-800 dark:text-white shadow-lg border border-gray-200 dark:border-gray-700 focus:outline-none data-[closed]:scale-95 data-[closed]:opacity-0"
>
<MenuItem>
<button className="group flex w-full items-center gap-2 rounded-lg py-1.5 px-3 hover:bg-gray-100 dark:hover:bg-gray-700">
<img
src={logoImg}
className="w-4 h-4"
alt={t("assistant.message.logo")}
/>
Coco AI
</button>
</MenuItem>
</MenuItems>
</Menu>
<button
onClick={onCreateNewChat}
@@ -84,44 +173,107 @@ export function ChatHeader({ onCreateNewChat, onOpenChatAI }: ChatHeaderProps) {
<div className="flex items-center gap-2">
<button
onClick={() => setIsPinned(!isPinned)}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 ${
isPinned ? 'text-blue-500' : ''
onClick={togglePin}
className={`rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 ${
isPinned ? "text-blue-500" : ""
}`}
>
<Pin className="h-4 w-4" />
{isPinned ? (
<Pin className="h-4 w-4" />
) : (
<PinOff className="h-4 w-4" />
)}
</button>
<button
onClick={onOpenChatAI}
className="rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
>
<PictureInPicture2 className="h-4 w-4" />
</button>
<Popover className="relative">
<Popover.Button className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
<PopoverButton className="flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
<MoreHorizontal className="h-4 w-4" />
</Popover.Button>
</PopoverButton>
<Popover.Panel className="absolute right-0 mt-2 w-60 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-2">
<h3 className="font-medium mb-2">Servers</h3>
<div className="space-y-2">
{servers.map(server => (
<div key={server.id} className="flex items-center justify-between p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg">
<PopoverPanel className="absolute right-0 z-10 mt-2 min-w-[240px] bg-white dark:bg-[#202126] rounded-lg shadow-lg border border-gray-200 dark:border-gray-700">
<div className="p-3">
<div className="flex items-center justify-between mb-3 whitespace-nowrap">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Servers
</h3>
<div className="flex items-center gap-2">
<button
onClick={openSettings}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
>
<Settings className="h-4 w-4 text-[#0287FF]" />
</button>
<button
onClick={async () => {
setIsRefreshing(true);
await fetchServers(false);
setTimeout(() => setIsRefreshing(false), 1000);
}}
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 dark:text-gray-400"
disabled={isRefreshing}
>
<RefreshCw
className={`h-4 w-4 text-[#0287FF] transition-transform duration-1000 ${
isRefreshing ? "animate-spin" : ""
}`}
/>
</button>
</div>
</div>
<div className="space-y-1">
{serverList.map((server) => (
<button
key={server.id}
onClick={() => switchServer(server)}
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors whitespace-nowrap ${
activeServer?.id === server.id
? "bg-gray-100 dark:bg-gray-800"
: "hover:bg-gray-50 dark:hover:bg-gray-800/50"
}`}
>
<div className="flex items-center gap-2">
<img src="/path-to-server-icon.png" className="w-6 h-6 rounded-full" />
<div>
<div className="font-medium">{server.name}</div>
<div className="text-xs text-gray-500">
AI Assistant: {server.assistantCount}
<img
src={server?.provider?.icon || logoImg}
alt={server.name}
className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-800"
/>
<div className="text-left">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{server.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
AI Assistant: {server.assistantCount || 1}
</div>
</div>
</div>
<span className={`w-2 h-2 rounded-full ${
server.status === 'online' ? 'bg-green-500' : 'bg-gray-400'
}`} />
</div>
<div className="flex items-center gap-2">
<span
className={`w-3 h-3 rounded-full ${
server.available
? "bg-[#00B926]"
: "bg-gray-400 dark:bg-gray-600"
}`}
/>
<div className="w-4 h-4">
{activeServer?.id === server.id && (
<Check className="w-full h-full text-gray-500 dark:text-gray-400" />
)}
</div>
</div>
</button>
))}
</div>
</div>
</Popover.Panel>
</PopoverPanel>
</Popover>
</div>
</header>
);
}
}

View File

@@ -3,7 +3,7 @@ import {
ChevronUp,
ChevronDown,
SquareArrowOutUpRight,
File,
Globe,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -80,7 +80,7 @@ export function SourceResult({ text }: SourceResultProps) {
>
<div className="flex-1 min-w-0 flex items-center gap-2">
<div className="flex-1 min-w-0 flex items-center gap-1">
<File className="w-3 h-3" />
<Globe className="w-3 h-3" />
<div className="text-xs text-[#333333] dark:text-[#D8D8D8] truncate font-normal group-hover:text-[#0072FF] dark:group-hover:text-[#0072FF]">
{item.title || item.category}
</div>

View File

@@ -1,11 +1,19 @@
import { ArrowDown01, Command, CornerDownLeft } from "lucide-react";
import {
ArrowDown01,
Command,
CornerDownLeft,
Pin,
PinOff,
} from "lucide-react";
import { emit } from "@tauri-apps/api/event";
import { useTranslation } from "react-i18next";
import { getCurrentWindow } from "@tauri-apps/api/window";
import logoImg from "@/assets/icon.svg";
import { useSearchStore } from "@/stores/searchStore";
import { isMac } from "@/utils/keyboardUtils";
import TypeIcon from "@/components/Common/Icons/TypeIcon";
import { useAppStore } from "@/stores/appStore";
interface FooterProps {
isChat: boolean;
@@ -16,10 +24,24 @@ export default function Footer({}: FooterProps) {
const { t } = useTranslation();
const sourceData = useSearchStore((state) => state.sourceData);
const isPinned = useAppStore((state) => state.isPinned);
const setIsPinned = useAppStore((state) => state.setIsPinned);
function openSetting() {
emit("open_settings", "");
}
const togglePin = async () => {
try {
const newPinned = !isPinned;
await getCurrentWindow().setAlwaysOnTop(newPinned);
setIsPinned(newPinned);
} catch (err) {
console.error("Failed to toggle window pin state:", err);
setIsPinned(isPinned);
}
};
return (
<div
data-tauri-drag-region
@@ -34,18 +56,31 @@ export default function Footer({}: FooterProps) {
src={logoImg}
className="w-4 h-4 cursor-pointer"
onClick={openSetting}
alt={t('search.footer.logoAlt')}
alt={t("search.footer.logoAlt")}
/>
)}
<span className="text-xs text-gray-500 dark:text-gray-400">
{sourceData?.source?.name || t('search.footer.version', { version: 'v1.0.0' })}
{sourceData?.source?.name ||
t("search.footer.version", { version: process.env.VERSION || "v1.0.0" })}
</span>
<button
onClick={togglePin}
className={`rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 ${
isPinned ? "text-blue-500" : ""
}`}
>
{isPinned ? (
<Pin className="h-3 w-3" />
) : (
<PinOff className="h-3 w-3" />
)}
</button>
</div>
</div>
<div className="flex items-center gap-3">
<div className="gap-1 flex items-center text-[#666] dark:text-[#666] text-xs">
<span className="mr-1.5">{t('search.footer.select')}:</span>
<span className="mr-1.5">{t("search.footer.select")}:</span>
<kbd className="coco-modal-footer-commands-key pr-1">
{isMac ? (
<Command className="w-3 h-3" />
@@ -61,7 +96,7 @@ export default function Footer({}: FooterProps) {
</kbd>
</div>
<div className="flex items-center text-[#666] dark:text-[#666] text-xs">
<span className="mr-1.5">{t('search.footer.open')}: </span>
<span className="mr-1.5">{t("search.footer.open")}: </span>
<kbd className="coco-modal-footer-commands-key pr-1">
<CornerDownLeft className="w-3 h-3" />
</kbd>

View File

@@ -47,6 +47,8 @@ export default function ChatInput({
(state: { showTooltip: boolean }) => state.showTooltip
);
const isPinned = useAppStore((state) => state.isPinned);
const sourceData = useSearchStore(
(state: { sourceData: any }) => state.sourceData
);
@@ -84,7 +86,7 @@ export default function ChatInput({
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
console.log("handleKeyDown", e.code, e.key);
// console.log("handleKeyDown", e.code, e.key);
if (e.key === "Escape") {
console.log("Escape:" + inputValue);
@@ -93,13 +95,12 @@ export default function ChatInput({
return;
} else {
console.log("empty value, but Escape key pressed.");
invoke("hide_coco")
.then(() => {
console.log("Hide Coco");
})
.finally(() => {
console.log("Hide Coco");
});
if (isPinned) {
return;
}
invoke("hide_coco").then(() => {
console.log("Hide Coco");
});
}
}

View File

@@ -37,7 +37,7 @@ export default function AboutView() {
</button>
</div>
<div className="mt-8 text-sm text-gray-500 dark:text-gray-400">
{t('settings.about.version', { version: '1.0.0' })}
{t('settings.about.version', { version: process.env.VERSION || "v1.0.0" })}
</div>
<div className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{t('settings.about.copyright', { year: new Date().getFullYear() })}

View File

@@ -1,8 +1,10 @@
import { useEffect, useCallback } from "react";
import { listen } from "@tauri-apps/api/event";
import { listen} from "@tauri-apps/api/event";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { isMetaOrCtrlKey } from "@/utils/keyboardUtils";
import { useAppStore } from "@/stores/appStore";
interface CreateWindowOptions {
label?: string;
title?: string;
@@ -15,6 +17,8 @@ interface CreateWindowOptions {
}
export default function useSettingsWindow() {
const setTabIndex = useAppStore((state) => state.setTabIndex);
const openSettingsWindow = useCallback((tab?: string) => {
const url = tab ? `/ui/settings?tab=${tab}` : `/ui/settings`;
const options: CreateWindowOptions = {
@@ -65,8 +69,9 @@ export default function useSettingsWindow() {
useEffect(() => {
const unlisten = listen("open_settings", (event) => {
console.log("open_settings event received:", event);
const tab = event.payload as string | undefined;
const tab = event.payload as string | "";
setTabIndex(tab)
openSettingsWindow(tab);
});
window.addEventListener("keydown", handleKeyDown);

View File

@@ -16,20 +16,22 @@ export default function DesktopApp() {
(state) => state.initializeListeners
);
const isPinned = useAppStore((state) => state.isPinned);
useEffect(() => {
initializeListeners();
initializeListeners_auth();
// Listen for window focus and blur events
const handleBlur = () => {
const handleBlur = async () => {
console.log("Window blurred");
invoke("hide_coco")
.then(() => {
console.log("Hide Coco");
})
.finally(() => {
console.log("Hide Coco");
});
if (isPinned) {
return;
}
invoke("hide_coco").then(() => {
console.log("Hide Coco");
});
};
const handleFocus = () => {
@@ -45,7 +47,7 @@ export default function DesktopApp() {
window.removeEventListener("blur", handleBlur);
window.removeEventListener("focus", handleFocus);
};
}, []);
}, [isPinned]);
const chatAIRef = useRef<ChatAIRef>(null);

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useState } from "react";
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react";
import { Settings, Puzzle, Settings2, Info, Server } from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import SettingsPanel from "@/components/Settings/SettingsPanel";
@@ -10,17 +9,13 @@ import AboutView from "@/components/Settings/AboutView";
import Cloud from "@/components/Cloud/Cloud.tsx"
import Footer from "@/components/Footer";
import ApiDetails from "@/components/Common/ApiDetails";
import { useAppStore } from "@/stores/appStore";
function SettingsPage() {
const { t } = useTranslation();
const [defaultIndex, setDefaultIndex] = useState<number>(0);
const [searchParams] = useSearchParams();
const name = searchParams.get("tab");
useEffect(() => {
setDefaultIndex(name === "about" ? 5 : 0);
}, [name]);
const tabs = [
{ name: t('settings.tabs.general'), icon: Settings },
@@ -29,6 +24,9 @@ function SettingsPage() {
{ name: t('settings.tabs.advanced'), icon: Settings2 },
{ name: t('settings.tabs.about'), icon: Info },
];
const tabIndex = useAppStore((state) => state.tabIndex);
const [defaultIndex, setDefaultIndex] = useState<number>(tabIndex);
return (
<div>

View File

@@ -1,4 +1,4 @@
import { createBrowserRouter } from "react-router-dom";
import {createBrowserRouter} from "react-router-dom";
import Layout from "./layout";
import ErrorPage from "@/error-page";
@@ -7,14 +7,14 @@ import SettingsPage from "@/pages/settings/index";
import ChatAI from "@/pages/chat/index";
export const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ path: "/ui", element: <DesktopApp /> },
{ path: "/ui/settings", element: <SettingsPage /> },
{ path: "/ui/chat", element: <ChatAI /> },
],
},
{
path: "/",
element: <Layout/>,
errorElement: <ErrorPage/>,
children: [
{path: "/ui", element: <DesktopApp/>},
{path: "/ui/settings", element: <SettingsPage/>},
{ path: "/ui/chat", element: <ChatAI /> },
],
},
]);

View File

@@ -6,6 +6,17 @@ import { AppEndpoint } from "@/utils/tauri"
const ENDPOINT_CHANGE_EVENT = 'endpoint-changed';
export interface IServer {
id: string;
name: string;
available: boolean;
endpoint: string;
provider: {
icon: string;
};
assistantCount?: number;
}
export type IAppStore = {
showTooltip: boolean;
setShowTooltip: (showTooltip: boolean) => void;
@@ -25,6 +36,12 @@ export type IAppStore = {
setEndpoint: (endpoint: AppEndpoint) => void,
language: string;
setLanguage: (language: string) => void;
tabIndex: number;
setTabIndex: (tabName: string) => void;
isPinned: boolean,
setIsPinned: (isPinned: boolean) => void,
activeServer: IServer | null,
setActiveServer: (activeServer: IServer | null) => void,
initializeListeners: () => void;
};
@@ -65,6 +82,21 @@ export const useAppStore = create<IAppStore>()(
},
language: "en",
setLanguage: (language: string) => set({ language }),
tabIndex: 0,
setTabIndex: (tabName: string) => {
const tabIndexMap: { [key: string]: number } = {
'general': 0,
'extensions': 1,
'connect': 2,
'advanced': 3,
'about': 4
};
set({ tabIndex: tabIndexMap[tabName || "general"] || 0 })
},
isPinned: false,
setIsPinned: (isPinned: boolean) => set({ isPinned }),
activeServer: null,
setActiveServer: (activeServer: IServer | null) => set({ activeServer }),
initializeListeners: () => {
listen(ENDPOINT_CHANGE_EVENT, (event: any) => {
const { endpoint, endpoint_http, endpoint_websocket } = event.payload;
@@ -83,6 +115,7 @@ export const useAppStore = create<IAppStore>()(
endpoint_http: state.endpoint_http,
endpoint_websocket: state.endpoint_websocket,
language: state.language,
activeServer: state.activeServer,
}),
}
)

View File

@@ -11,6 +11,8 @@ export type IChatStore = {
setStopChat: (value: boolean) => void;
connected: boolean;
setConnected: (value: boolean) => void;
messages: string;
setMessages: (value: string | ((prev: string) => string)) => void;
};
export const useChatStore = create<IChatStore>()(
@@ -22,6 +24,11 @@ export const useChatStore = create<IChatStore>()(
setStopChat: (value: boolean) => set(() => ({ stopChat: value })),
connected: false,
setConnected: (value: boolean) => set(() => ({ connected: value })),
messages: "",
setMessages: (value: string | ((prev: string) => string)) =>
set((state) => ({
messages: typeof value === "function" ? value(state.messages) : value,
})),
}),
{
name: "chat-state",

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from 'path';
import { config } from "dotenv";
import packageJson from './package.json';
config();
@@ -10,6 +11,9 @@ const host = process.env.TAURI_DEV_HOST;
// https://vitejs.dev/config/
export default defineConfig(async () => ({
define: {
'process.env.VERSION': JSON.stringify(packageJson.version),
},
plugins: [react()],
resolve: {
alias: {