From f2e2e96b3e0ce29d7703b68c527ac1f9e2b93e14 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Wed, 10 Dec 2025 22:58:09 -0300 Subject: [PATCH] fix: suite chat now works like minimal UI with HTMX loading - Fixed chat layout CSS to use absolute positioning for proper HTMX loading - Fixed CSS path from relative to absolute (/chat/chat.css) - Added chat CSS to main index.html for pre-loading - Added HTMX afterSettle event listener to initialize chat module - Added WebSocket proxy for SSL cert handling (Cargo.toml, mod.rs) --- Cargo.lock | 40 ++++++- Cargo.toml | 2 + src/ui_server/mod.rs | 260 +++++++++++++++++++++++++++++++++++++++- ui/suite/chat/chat.css | 260 +++++++++++++++++++++++++++++++++++++++- ui/suite/chat/chat.html | 230 +++++++++++++++++++++++++++++------ ui/suite/index.html | 185 +++++++++++++++++++++++++++- 6 files changed, 931 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9609a64..6f14092 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,7 +186,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower 0.5.2", "tower-layer", "tower-service", @@ -293,6 +293,7 @@ dependencies = [ "local-ip-address", "log", "mime_guess", + "native-tls", "rand", "regex", "reqwest", @@ -301,6 +302,7 @@ dependencies = [ "time", "tokio", "tokio-stream", + "tokio-tungstenite 0.21.0", "tower 0.4.13", "tower-cookies", "tower-http 0.5.2", @@ -2285,6 +2287,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite 0.21.0", +] + [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -2294,7 +2310,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite 0.24.0", ] [[package]] @@ -2488,6 +2504,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + [[package]] name = "tungstenite" version = "0.24.0" diff --git a/Cargo.toml b/Cargo.toml index 6317f0c..764b9ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ jsonwebtoken = "9.3" local-ip-address = "0.6.5" log = "0.4" mime_guess = "2.0" +native-tls = "0.2" rand = "0.8" regex = "1.10" reqwest = { version = "0.12", features = ["json"] } @@ -40,6 +41,7 @@ serde_json = "1.0" time = "0.3" tokio = { version = "1.41", features = ["full"] } tokio-stream = "0.1" +tokio-tungstenite = { version = "0.21", features = ["native-tls"] } tower = "0.4" tower-http = { version = "0.5", features = ["cors", "fs", "trace"] } tower-cookies = "0.10" diff --git a/src/ui_server/mod.rs b/src/ui_server/mod.rs index 96519b6..4f4196d 100644 --- a/src/ui_server/mod.rs +++ b/src/ui_server/mod.rs @@ -3,14 +3,24 @@ //! Serves the web UI (suite, minimal) and handles API proxying. use axum::{ - extract::State, - http::StatusCode, - response::{Html, IntoResponse}, - routing::get, + body::Body, + extract::{ + ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade}, + OriginalUri, Query, State, + }, + http::{Request, StatusCode}, + response::{Html, IntoResponse, Response}, + routing::{any, get}, Router, }; -use log::error; +use futures_util::{SinkExt, StreamExt}; +use log::{debug, error, info}; +use serde::Deserialize; use std::{fs, path::PathBuf}; +use tokio_tungstenite::{ + connect_async_tls_with_config, + tungstenite::{self, protocol::Message as TungsteniteMessage}, +}; use crate::shared::AppState; @@ -82,6 +92,241 @@ async fn api_health() -> (StatusCode, axum::Json) { ) } +/// Proxy API requests to botserver +async fn proxy_api( + State(state): State, + original_uri: OriginalUri, + req: Request, +) -> Response { + let path = original_uri.path(); + let query = original_uri.query().map(|q| format!("?{}", q)).unwrap_or_default(); + let method = req.method().clone(); + let headers = req.headers().clone(); + + let target_url = format!("{}{}{}", state.client.base_url(), path, query); + debug!("Proxying {} {} to {}", method, path, target_url); + + // Build the proxied request with self-signed cert support + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let mut proxy_req = client.request(method.clone(), &target_url); + + // Copy headers (excluding host) + for (name, value) in headers.iter() { + if name != "host" { + if let Ok(v) = value.to_str() { + proxy_req = proxy_req.header(name.as_str(), v); + } + } + } + + // Copy body for non-GET requests + let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await { + Ok(bytes) => bytes, + Err(e) => { + error!("Failed to read request body: {}", e); + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Failed to read request body")) + .unwrap(); + } + }; + + if !body_bytes.is_empty() { + proxy_req = proxy_req.body(body_bytes.to_vec()); + } + + // Execute the request + match proxy_req.send().await { + Ok(resp) => { + let status = resp.status(); + let headers = resp.headers().clone(); + + match resp.bytes().await { + Ok(body) => { + let mut response = Response::builder().status(status); + + // Copy response headers + for (name, value) in headers.iter() { + response = response.header(name, value); + } + + response.body(Body::from(body)).unwrap_or_else(|_| { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(Body::from("Failed to build response")) + .unwrap() + }) + } + Err(e) => { + error!("Failed to read response body: {}", e); + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from(format!("Failed to read response: {}", e))) + .unwrap() + } + } + } + Err(e) => { + error!("Proxy request failed: {}", e); + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .body(Body::from(format!("Proxy error: {}", e))) + .unwrap() + } + } +} + +/// Create API proxy router +fn create_api_router() -> Router { + Router::new() + .route("/health", get(api_health)) + .route("/chat", any(proxy_api)) + .route("/sessions", any(proxy_api)) + .route("/sessions/{id}", any(proxy_api)) + .route("/sessions/{id}/history", any(proxy_api)) + .route("/sessions/{id}/start", any(proxy_api)) + .route("/drive/files", any(proxy_api)) + .route("/drive/files/{path}", any(proxy_api)) + .route("/drive/upload", any(proxy_api)) + .route("/drive/download/{path}", any(proxy_api)) + .route("/tasks", any(proxy_api)) + .route("/tasks/{id}", any(proxy_api)) + .fallback(any(proxy_api)) +} + +/// WebSocket query parameters +#[derive(Debug, Deserialize)] +struct WsQuery { + session_id: String, + user_id: String, +} + +/// WebSocket proxy handler +async fn ws_proxy( + ws: WebSocketUpgrade, + State(state): State, + Query(params): Query, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params)) +} + +/// Handle WebSocket proxy connection +async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) { + let backend_url = format!( + "{}/ws?session_id={}&user_id={}", + state.client.base_url().replace("https://", "wss://").replace("http://", "ws://"), + params.session_id, + params.user_id + ); + + info!("Proxying WebSocket to: {}", backend_url); + + // Create TLS connector that accepts self-signed certs + let tls_connector = native_tls::TlsConnector::builder() + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true) + .build() + .expect("Failed to build TLS connector"); + + let connector = tokio_tungstenite::Connector::NativeTls(tls_connector); + + // Connect to backend WebSocket + let backend_result = connect_async_tls_with_config( + &backend_url, + None, + false, + Some(connector), + ).await; + + let backend_socket = match backend_result { + Ok((socket, _)) => socket, + Err(e) => { + error!("Failed to connect to backend WebSocket: {}", e); + return; + } + }; + + info!("Connected to backend WebSocket"); + + // Split both sockets + let (mut client_tx, mut client_rx) = client_socket.split(); + let (mut backend_tx, mut backend_rx) = backend_socket.split(); + + // Forward messages from client to backend + let client_to_backend = async { + while let Some(msg) = client_rx.next().await { + match msg { + Ok(AxumMessage::Text(text)) => { + if backend_tx.send(TungsteniteMessage::Text(text)).await.is_err() { + break; + } + } + Ok(AxumMessage::Binary(data)) => { + if backend_tx.send(TungsteniteMessage::Binary(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Ping(data)) => { + if backend_tx.send(TungsteniteMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Pong(data)) => { + if backend_tx.send(TungsteniteMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(AxumMessage::Close(_)) | Err(_) => break, + } + } + }; + + // Forward messages from backend to client + let backend_to_client = async { + while let Some(msg) = backend_rx.next().await { + match msg { + Ok(TungsteniteMessage::Text(text)) => { + if client_tx.send(AxumMessage::Text(text)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Binary(data)) => { + if client_tx.send(AxumMessage::Binary(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Ping(data)) => { + if client_tx.send(AxumMessage::Ping(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Pong(data)) => { + if client_tx.send(AxumMessage::Pong(data)).await.is_err() { + break; + } + } + Ok(TungsteniteMessage::Close(_)) | Err(_) => break, + _ => {} + } + } + }; + + // Run both forwarding tasks concurrently + tokio::select! { + _ = client_to_backend => info!("Client connection closed"), + _ = backend_to_client => info!("Backend connection closed"), + } +} + +/// Create WebSocket proxy router +fn create_ws_router() -> Router { + Router::new() + .fallback(get(ws_proxy)) +} + /// Configure and return the main router pub fn configure_router() -> Router { let suite_path = PathBuf::from("./ui/suite"); @@ -91,7 +336,10 @@ pub fn configure_router() -> Router { Router::new() // Health check endpoints .route("/health", get(health)) - .route("/api/health", get(api_health)) + // API proxy routes + .nest("/api", create_api_router()) + // WebSocket proxy routes + .nest("/ws", create_ws_router()) // UI routes .route("/", get(index)) .route("/minimal", get(serve_minimal)) diff --git a/ui/suite/chat/chat.css b/ui/suite/chat/chat.css index 4d411a6..ce5e703 100644 --- a/ui/suite/chat/chat.css +++ b/ui/suite/chat/chat.css @@ -1,4 +1,262 @@ -/* Chat module styles - including projector component */ +/* Chat module styles - including chat layout and projector component */ + +/* Chat Layout */ +.chat-layout { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + max-width: 800px; + margin: 0 auto; + padding: 80px 20px 20px 20px; + box-sizing: border-box; +} + +/* Connection Status */ +.connection-status { + position: fixed; + top: 80px; + right: 20px; + width: 10px; + height: 10px; + border-radius: 50%; + z-index: 1000; + transition: all 0.3s; +} + +.connection-status.connecting { + background: #f59e0b; + animation: pulse 1.5s infinite; +} + +.connection-status.connected { + background: #10b981; +} + +.connection-status.disconnected { + background: #ef4444; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(1.2); } +} + +/* Messages Area */ +#messages { + flex: 1; + overflow-y: auto; + padding: 20px 0; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Message Styles */ +.message { + display: flex; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.message.user { + justify-content: flex-end; +} + +.message.bot { + justify-content: flex-start; +} + +.message-content { + max-width: 80%; + padding: 12px 16px; + border-radius: 16px; + line-height: 1.5; + font-size: 14px; +} + +.user-message { + background: var(--primary-color, #3b82f6); + color: white; + border-bottom-right-radius: 4px; +} + +.bot-message { + background: var(--surface-color, #f3f4f6); + color: var(--text-color, #1f2937); + border-bottom-left-radius: 4px; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .bot-message { + background: #374151; + color: #f9fafb; +} + +/* Markdown content in bot messages */ +.bot-message p { + margin: 0 0 8px 0; +} + +.bot-message p:last-child { + margin-bottom: 0; +} + +.bot-message code { + background: rgba(0, 0, 0, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 13px; +} + +.bot-message pre { + background: rgba(0, 0, 0, 0.1); + padding: 12px; + border-radius: 8px; + overflow-x: auto; + margin: 8px 0; +} + +.bot-message pre code { + background: none; + padding: 0; +} + +/* Footer */ +footer { + padding: 16px 0; + border-top: 1px solid var(--border-color, #e5e7eb); + background: var(--background-color, #ffffff); +} + +/* Suggestions */ +.suggestions-container { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.suggestion-button { + padding: 6px 12px; + border-radius: 16px; + border: 1px solid var(--border-color, #e5e7eb); + background: var(--surface-color, #f9fafb); + color: var(--text-color, #374151); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.suggestion-button:hover { + background: var(--primary-color, #3b82f6); + color: white; + border-color: var(--primary-color, #3b82f6); +} + +/* Input Container */ +.input-container { + display: flex; + gap: 8px; + align-items: center; +} + +#messageInput { + flex: 1; + padding: 12px 16px; + border-radius: 24px; + border: 1px solid var(--border-color, #e5e7eb); + background: var(--surface-color, #f9fafb); + color: var(--text-color, #1f2937); + font-size: 14px; + outline: none; + transition: all 0.2s; +} + +#messageInput:focus { + border-color: var(--primary-color, #3b82f6); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +#messageInput::placeholder { + color: var(--text-muted, #9ca3af); +} + +#voiceBtn, +#sendBtn { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: var(--primary-color, #3b82f6); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s; +} + +#voiceBtn:hover, +#sendBtn:hover { + transform: scale(1.05); + background: var(--primary-hover, #2563eb); +} + +#voiceBtn:active, +#sendBtn:active { + transform: scale(0.95); +} + +/* Scroll to Bottom Button */ +.scroll-to-bottom { + position: fixed; + bottom: 100px; + right: 20px; + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid var(--border-color, #e5e7eb); + background: var(--background-color, #ffffff); + color: var(--text-color, #374151); + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 100; +} + +.scroll-to-bottom.visible { + display: flex; +} + +.scroll-to-bottom:hover { + background: var(--surface-color, #f3f4f6); +} + +/* Responsive */ +@media (max-width: 768px) { + .chat-layout { + padding: 0 12px; + } + + .message-content { + max-width: 90%; + } +} + /* Projector Overlay */ .projector-overlay { diff --git a/ui/suite/chat/chat.html b/ui/suite/chat/chat.html index 57734bc..1f7a32c 100644 --- a/ui/suite/chat/chat.html +++ b/ui/suite/chat/chat.html @@ -1,47 +1,209 @@ -
+ + + +
-
+
-
-
+
+
- - - + + +
-
diff --git a/ui/suite/index.html b/ui/suite/index.html index 5592a3c..8d66ec0 100644 --- a/ui/suite/index.html +++ b/ui/suite/index.html @@ -14,12 +14,19 @@ + + + + @@ -99,7 +106,7 @@ data-section="chat" role="menuitem" aria-label="Chat application" - hx-get="/api/chat" + hx-get="chat/chat.html" hx-target="#main-content" hx-push-url="true" > @@ -409,8 +416,6 @@
@@ -421,6 +426,180 @@