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",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.24.0",
|
||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -293,6 +293,7 @@ dependencies = [
|
||||||
"local-ip-address",
|
"local-ip-address",
|
||||||
"log",
|
"log",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"native-tls",
|
||||||
"rand",
|
"rand",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -301,6 +302,7 @@ dependencies = [
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tokio-tungstenite 0.21.0",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-cookies",
|
"tower-cookies",
|
||||||
"tower-http 0.5.2",
|
"tower-http 0.5.2",
|
||||||
|
|
@ -2285,6 +2287,20 @@ dependencies = [
|
||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
|
|
@ -2294,7 +2310,7 @@ dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite",
|
"tungstenite 0.24.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2488,6 +2504,26 @@ version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
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]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ jsonwebtoken = "9.3"
|
||||||
local-ip-address = "0.6.5"
|
local-ip-address = "0.6.5"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
mime_guess = "2.0"
|
mime_guess = "2.0"
|
||||||
|
native-tls = "0.2"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
|
@ -40,6 +41,7 @@ serde_json = "1.0"
|
||||||
time = "0.3"
|
time = "0.3"
|
||||||
tokio = { version = "1.41", features = ["full"] }
|
tokio = { version = "1.41", features = ["full"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||||
tower = "0.4"
|
tower = "0.4"
|
||||||
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
||||||
tower-cookies = "0.10"
|
tower-cookies = "0.10"
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,24 @@
|
||||||
//! Serves the web UI (suite, minimal) and handles API proxying.
|
//! Serves the web UI (suite, minimal) and handles API proxying.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
body::Body,
|
||||||
http::StatusCode,
|
extract::{
|
||||||
response::{Html, IntoResponse},
|
ws::{Message as AxumMessage, WebSocket, WebSocketUpgrade},
|
||||||
routing::get,
|
OriginalUri, Query, State,
|
||||||
|
},
|
||||||
|
http::{Request, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::{any, get},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use log::error;
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use log::{debug, error, info};
|
||||||
|
use serde::Deserialize;
|
||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
use tokio_tungstenite::{
|
||||||
|
connect_async_tls_with_config,
|
||||||
|
tungstenite::{self, protocol::Message as TungsteniteMessage},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::shared::AppState;
|
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
|
/// Configure and return the main router
|
||||||
pub fn configure_router() -> Router {
|
pub fn configure_router() -> Router {
|
||||||
let suite_path = PathBuf::from("./ui/suite");
|
let suite_path = PathBuf::from("./ui/suite");
|
||||||
|
|
@ -91,7 +336,10 @@ pub fn configure_router() -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
// Health check endpoints
|
// Health check endpoints
|
||||||
.route("/health", get(health))
|
.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
|
// UI routes
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
.route("/minimal", get(serve_minimal))
|
.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 */
|
||||||
.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>
|
<div id="connectionStatus" class="connection-status disconnected"></div>
|
||||||
<main
|
<main id="messages"></main>
|
||||||
id="messages"
|
|
||||||
hx-get="/api/sessions/current/history"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
></main>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div
|
<div class="suggestions-container" id="suggestions"></div>
|
||||||
class="suggestions-container"
|
<div class="input-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()"
|
|
||||||
>
|
|
||||||
<input
|
<input
|
||||||
name="content"
|
name="content"
|
||||||
id="messageInput"
|
id="messageInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Message..."
|
placeholder="Message..."
|
||||||
autofocus
|
autofocus
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="button" id="voiceBtn" title="Voice">🎤</button>
|
||||||
type="button"
|
<button type="button" id="sendBtn" title="Send">↑</button>
|
||||||
id="voiceBtn"
|
</div>
|
||||||
title="Voice"
|
|
||||||
hx-post="/api/voice/start"
|
|
||||||
hx-swap="none"
|
|
||||||
>
|
|
||||||
🎤
|
|
||||||
</button>
|
|
||||||
<button type="submit" id="sendBtn" title="Send">↑</button>
|
|
||||||
</form>
|
|
||||||
</footer>
|
</footer>
|
||||||
<button class="scroll-to-bottom" id="scrollToBottom">↓</button>
|
<button class="scroll-to-bottom" id="scrollToBottom">↓</button>
|
||||||
<div class="flash-overlay" id="flashOverlay"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,19 @@
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<link rel="stylesheet" href="css/app.css" />
|
||||||
<link rel="stylesheet" href="css/apps-extended.css" />
|
<link rel="stylesheet" href="css/apps-extended.css" />
|
||||||
<link rel="stylesheet" href="css/components.css" />
|
<link rel="stylesheet" href="css/components.css" />
|
||||||
|
<link rel="stylesheet" href="chat/chat.css" />
|
||||||
|
|
||||||
<!-- Local Libraries (no external CDN dependencies) -->
|
<!-- Local Libraries (no external CDN dependencies) -->
|
||||||
<script src="js/vendor/htmx.min.js"></script>
|
<script src="js/vendor/htmx.min.js"></script>
|
||||||
<script src="js/vendor/htmx-ws.js"></script>
|
<script src="js/vendor/htmx-ws.js"></script>
|
||||||
<script src="js/vendor/htmx-json-enc.js"></script>
|
<script src="js/vendor/htmx-json-enc.js"></script>
|
||||||
<script src="js/vendor/marked.min.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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -99,7 +106,7 @@
|
||||||
data-section="chat"
|
data-section="chat"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
aria-label="Chat application"
|
aria-label="Chat application"
|
||||||
hx-get="/api/chat"
|
hx-get="chat/chat.html"
|
||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
>
|
>
|
||||||
|
|
@ -409,8 +416,6 @@
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
role="main"
|
role="main"
|
||||||
hx-ext="ws"
|
|
||||||
ws-connect="/ws/notifications"
|
|
||||||
>
|
>
|
||||||
<!-- Sections will be loaded dynamically -->
|
<!-- Sections will be loaded dynamically -->
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -421,6 +426,180 @@
|
||||||
|
|
||||||
<!-- Application initialization -->
|
<!-- Application initialization -->
|
||||||
<script>
|
<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
|
// Simple initialization for HTMX app
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
console.log("🚀 Initializing General Bots with HTMX...");
|
console.log("🚀 Initializing General Bots with HTMX...");
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue