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:
Rodrigo Rodriguez (Pragmatismo) 2025-12-10 22:58:09 -03:00
parent f12e858f74
commit f2e2e96b3e
6 changed files with 931 additions and 46 deletions

40
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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