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)
This commit is contained in:
parent
f12e858f74
commit
f2e2e96b3e
6 changed files with 931 additions and 46 deletions
40
Cargo.lock
generated
40
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value>) {
|
|||
)
|
||||
}
|
||||
|
||||
/// Proxy API requests to botserver
|
||||
async fn proxy_api(
|
||||
State(state): State<AppState>,
|
||||
original_uri: OriginalUri,
|
||||
req: Request<Body>,
|
||||
) -> Response<Body> {
|
||||
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<AppState> {
|
||||
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<AppState>,
|
||||
Query(params): Query<WsQuery>,
|
||||
) -> 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<AppState> {
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,47 +1,209 @@
|
|||
<div class="chat-layout" id="chat-app" hx-ext="ws" ws-connect="/ws">
|
||||
<link rel="stylesheet" href="/chat/chat.css" />
|
||||
<script>
|
||||
// WebSocket URL - use relative path to go through botui proxy
|
||||
const WS_BASE_URL = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const WS_URL = `${WS_BASE_URL}${window.location.host}`;
|
||||
|
||||
// Message Type Constants
|
||||
const MessageType = { EXTERNAL: 0, USER: 1, BOT_RESPONSE: 2, CONTINUE: 3, SUGGESTION: 4, CONTEXT_CHANGE: 5 };
|
||||
|
||||
// State
|
||||
let ws = null, currentSessionId = null, currentUserId = null, currentBotId = "default";
|
||||
let isStreaming = false, streamingMessageId = null, currentStreamingContent = "";
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
// Initialize auth and WebSocket
|
||||
async function initChat() {
|
||||
try {
|
||||
updateConnectionStatus('connecting');
|
||||
const botName = 'default';
|
||||
// Use the botui proxy for auth (handles SSL cert issues)
|
||||
const response = await fetch(`/api/auth?bot_name=${encodeURIComponent(botName)}`);
|
||||
const auth = await response.json();
|
||||
currentUserId = auth.user_id;
|
||||
currentSessionId = auth.session_id;
|
||||
currentBotId = auth.bot_id || "default";
|
||||
console.log("Auth:", { currentUserId, currentSessionId, currentBotId });
|
||||
connectWebSocket();
|
||||
} catch (e) {
|
||||
console.error("Auth failed:", e);
|
||||
updateConnectionStatus('disconnected');
|
||||
setTimeout(initChat, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
if (ws) ws.close();
|
||||
// Use the botui proxy for WebSocket (handles SSL cert issues)
|
||||
const url = `${WS_URL}/ws?session_id=${currentSessionId}&user_id=${currentUserId}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
updateConnectionStatus('connected');
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'connected') return;
|
||||
if (data.message_type === MessageType.BOT_RESPONSE) {
|
||||
processMessage(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("WS message error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
updateConnectionStatus('disconnected');
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
setTimeout(connectWebSocket, 1000 * reconnectAttempts);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error("WebSocket error:", e);
|
||||
}
|
||||
|
||||
function processMessage(data) {
|
||||
if (data.is_complete) {
|
||||
if (isStreaming) {
|
||||
finalizeStreaming();
|
||||
} else {
|
||||
addMessage('bot', data.content);
|
||||
}
|
||||
isStreaming = false;
|
||||
} else {
|
||||
if (!isStreaming) {
|
||||
isStreaming = true;
|
||||
streamingMessageId = 'streaming-' + Date.now();
|
||||
currentStreamingContent = data.content || '';
|
||||
addMessage('bot', currentStreamingContent, streamingMessageId);
|
||||
} else {
|
||||
currentStreamingContent += data.content || '';
|
||||
updateStreaming(currentStreamingContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(sender, content, msgId = null) {
|
||||
const messages = document.getElementById('messages');
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${sender}`;
|
||||
if (msgId) div.id = msgId;
|
||||
|
||||
if (sender === 'user') {
|
||||
div.innerHTML = `<div class="message-content user-message">${escapeHtml(content)}</div>`;
|
||||
} else {
|
||||
div.innerHTML = `<div class="message-content bot-message">${marked.parse(content)}</div>`;
|
||||
}
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function updateStreaming(content) {
|
||||
const el = document.getElementById(streamingMessageId);
|
||||
if (el) el.querySelector('.message-content').innerHTML = marked.parse(content);
|
||||
}
|
||||
|
||||
function finalizeStreaming() {
|
||||
const el = document.getElementById(streamingMessageId);
|
||||
if (el) {
|
||||
el.querySelector('.message-content').innerHTML = marked.parse(currentStreamingContent);
|
||||
el.removeAttribute('id');
|
||||
}
|
||||
streamingMessageId = null;
|
||||
currentStreamingContent = '';
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const content = input.value.trim();
|
||||
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
addMessage('user', content);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
bot_id: currentBotId,
|
||||
user_id: currentUserId,
|
||||
session_id: currentSessionId,
|
||||
channel: 'web',
|
||||
content: content,
|
||||
message_type: MessageType.USER,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function updateConnectionStatus(status) {
|
||||
const el = document.getElementById('connectionStatus');
|
||||
if (el) el.className = `connection-status ${status}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Initialize chat - runs immediately when script is executed
|
||||
// (works both on full page load and HTMX partial load)
|
||||
function setupChat() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
|
||||
if (sendBtn) sendBtn.onclick = sendMessage;
|
||||
if (input) {
|
||||
input.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') sendMessage();
|
||||
});
|
||||
}
|
||||
|
||||
initChat();
|
||||
}
|
||||
|
||||
// Initialize after a micro-delay to ensure DOM is ready
|
||||
// This works for both full page loads and HTMX partial loads
|
||||
setTimeout(() => {
|
||||
if (document.getElementById('messageInput') && !window.chatInitialized) {
|
||||
window.chatInitialized = true;
|
||||
setupChat();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
// Fallback for full page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (!window.chatInitialized) {
|
||||
window.chatInitialized = true;
|
||||
setupChat();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-layout" id="chat-app">
|
||||
<div id="connectionStatus" class="connection-status disconnected"></div>
|
||||
<main
|
||||
id="messages"
|
||||
hx-get="/api/sessions/current/history"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
></main>
|
||||
<main id="messages"></main>
|
||||
|
||||
<footer>
|
||||
<div
|
||||
class="suggestions-container"
|
||||
id="suggestions"
|
||||
hx-get="/api/suggestions"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML"
|
||||
></div>
|
||||
<form
|
||||
class="input-container"
|
||||
hx-post="/api/sessions/current/message"
|
||||
hx-target="#messages"
|
||||
hx-swap="beforeend"
|
||||
hx-on::after-request="this.reset()"
|
||||
>
|
||||
<div class="suggestions-container" id="suggestions"></div>
|
||||
<div class="input-container">
|
||||
<input
|
||||
name="content"
|
||||
id="messageInput"
|
||||
type="text"
|
||||
placeholder="Message..."
|
||||
autofocus
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="voiceBtn"
|
||||
title="Voice"
|
||||
hx-post="/api/voice/start"
|
||||
hx-swap="none"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
<button type="submit" id="sendBtn" title="Send">↑</button>
|
||||
</form>
|
||||
<button type="button" id="voiceBtn" title="Voice">🎤</button>
|
||||
<button type="button" id="sendBtn" title="Send">↑</button>
|
||||
</div>
|
||||
</footer>
|
||||
<button class="scroll-to-bottom" id="scrollToBottom">↓</button>
|
||||
<div class="flash-overlay" id="flashOverlay"></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,12 +14,19 @@
|
|||
<link rel="stylesheet" href="css/app.css" />
|
||||
<link rel="stylesheet" href="css/apps-extended.css" />
|
||||
<link rel="stylesheet" href="css/components.css" />
|
||||
<link rel="stylesheet" href="chat/chat.css" />
|
||||
|
||||
<!-- Local Libraries (no external CDN dependencies) -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
<script src="js/vendor/htmx-ws.js"></script>
|
||||
<script src="js/vendor/htmx-json-enc.js"></script>
|
||||
<script src="js/vendor/marked.min.js"></script>
|
||||
|
||||
<!-- Enable HTMX to process inline scripts in swapped content -->
|
||||
<script>
|
||||
htmx.config.allowEval = true;
|
||||
htmx.config.includeIndicatorStyles = false;
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
@ -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 @@
|
|||
<main
|
||||
id="main-content"
|
||||
role="main"
|
||||
hx-ext="ws"
|
||||
ws-connect="/ws/notifications"
|
||||
>
|
||||
<!-- Sections will be loaded dynamically -->
|
||||
</main>
|
||||
|
|
@ -421,6 +426,180 @@
|
|||
|
||||
<!-- Application initialization -->
|
||||
<script>
|
||||
// Chat initialization function (called after HTMX loads chat content)
|
||||
function initChatModule() {
|
||||
if (window.chatModuleInitialized) return;
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const sendBtn = document.getElementById('sendBtn');
|
||||
if (!messageInput || !sendBtn) return;
|
||||
|
||||
window.chatModuleInitialized = true;
|
||||
console.log("Initializing chat module...");
|
||||
|
||||
// WebSocket URL
|
||||
const WS_BASE_URL = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
const WS_URL = `${WS_BASE_URL}${window.location.host}`;
|
||||
|
||||
// Message Type Constants
|
||||
const MessageType = { EXTERNAL: 0, USER: 1, BOT_RESPONSE: 2, CONTINUE: 3, SUGGESTION: 4, CONTEXT_CHANGE: 5 };
|
||||
|
||||
// State
|
||||
let ws = null, currentSessionId = null, currentUserId = null, currentBotId = "default";
|
||||
let isStreaming = false, streamingMessageId = null, currentStreamingContent = "";
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
// Initialize auth and WebSocket
|
||||
async function initChat() {
|
||||
try {
|
||||
updateConnectionStatus('connecting');
|
||||
const response = await fetch(`/api/auth?bot_name=default`);
|
||||
const auth = await response.json();
|
||||
currentUserId = auth.user_id;
|
||||
currentSessionId = auth.session_id;
|
||||
currentBotId = auth.bot_id || "default";
|
||||
console.log("Auth:", { currentUserId, currentSessionId, currentBotId });
|
||||
connectWebSocket();
|
||||
} catch (e) {
|
||||
console.error("Auth failed:", e);
|
||||
updateConnectionStatus('disconnected');
|
||||
setTimeout(initChat, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
if (ws) ws.close();
|
||||
const url = `${WS_URL}/ws?session_id=${currentSessionId}&user_id=${currentUserId}`;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connected");
|
||||
updateConnectionStatus('connected');
|
||||
reconnectAttempts = 0;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'connected') return;
|
||||
if (data.message_type === MessageType.BOT_RESPONSE) {
|
||||
processMessage(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("WS message error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
updateConnectionStatus('disconnected');
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
setTimeout(connectWebSocket, 1000 * reconnectAttempts);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => console.error("WebSocket error:", e);
|
||||
}
|
||||
|
||||
function processMessage(data) {
|
||||
if (data.is_complete) {
|
||||
if (isStreaming) finalizeStreaming();
|
||||
else addMessage('bot', data.content);
|
||||
isStreaming = false;
|
||||
} else {
|
||||
if (!isStreaming) {
|
||||
isStreaming = true;
|
||||
streamingMessageId = 'streaming-' + Date.now();
|
||||
currentStreamingContent = data.content || '';
|
||||
addMessage('bot', currentStreamingContent, streamingMessageId);
|
||||
} else {
|
||||
currentStreamingContent += data.content || '';
|
||||
updateStreaming(currentStreamingContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function addMessage(sender, content, msgId = null) {
|
||||
const messages = document.getElementById('messages');
|
||||
if (!messages) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = `message ${sender}`;
|
||||
if (msgId) div.id = msgId;
|
||||
|
||||
if (sender === 'user') {
|
||||
div.innerHTML = `<div class="message-content user-message">${escapeHtml(content)}</div>`;
|
||||
} else {
|
||||
div.innerHTML = `<div class="message-content bot-message">${marked.parse(content)}</div>`;
|
||||
}
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function updateStreaming(content) {
|
||||
const el = document.getElementById(streamingMessageId);
|
||||
if (el) el.querySelector('.message-content').innerHTML = marked.parse(content);
|
||||
}
|
||||
|
||||
function finalizeStreaming() {
|
||||
const el = document.getElementById(streamingMessageId);
|
||||
if (el) {
|
||||
el.querySelector('.message-content').innerHTML = marked.parse(currentStreamingContent);
|
||||
el.removeAttribute('id');
|
||||
}
|
||||
streamingMessageId = null;
|
||||
currentStreamingContent = '';
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const content = input.value.trim();
|
||||
if (!content || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
addMessage('user', content);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
bot_id: currentBotId,
|
||||
user_id: currentUserId,
|
||||
session_id: currentSessionId,
|
||||
channel: 'web',
|
||||
content: content,
|
||||
message_type: MessageType.USER,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
|
||||
function updateConnectionStatus(status) {
|
||||
const el = document.getElementById('connectionStatus');
|
||||
if (el) el.className = `connection-status ${status}`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Setup event handlers
|
||||
sendBtn.onclick = sendMessage;
|
||||
messageInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') sendMessage();
|
||||
});
|
||||
|
||||
// Start chat
|
||||
initChat();
|
||||
}
|
||||
|
||||
// Listen for HTMX content swaps to initialize chat
|
||||
document.addEventListener('htmx:afterSettle', (e) => {
|
||||
if (document.getElementById('messageInput')) {
|
||||
window.chatModuleInitialized = false; // Reset flag for reinitialization
|
||||
initChatModule();
|
||||
}
|
||||
});
|
||||
|
||||
// Simple initialization for HTMX app
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("🚀 Initializing General Bots with HTMX...");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue