Update botui

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-21 23:40:44 -03:00
parent 1a50680712
commit 9a2596ed4a
7 changed files with 251 additions and 85 deletions

View file

@ -4,6 +4,9 @@ version = "6.1.0"
edition = "2021" edition = "2021"
description = "General Bots UI - Pure web interface" description = "General Bots UI - Pure web interface"
license = "AGPL-3.0" license = "AGPL-3.0"
repository = "https://github.com/GeneralBots/BotServer"
keywords = ["chatbot", "ui", "web-interface", "general-bots"]
categories = ["web-programming", "gui"]
[dependencies.botlib] [dependencies.botlib]
path = "../botlib" path = "../botlib"
@ -49,3 +52,22 @@ tracing = "0.1"
urlencoding = "2.1" urlencoding = "2.1"
uuid = { version = "1.11", features = ["serde", "v4"] } uuid = { version = "1.11", features = ["serde", "v4"] }
webbrowser = "0.8" webbrowser = "0.8"
[lints.rust]
unused_imports = "warn"
unused_variables = "warn"
unused_mut = "warn"
unsafe_code = "deny"
missing_debug_implementations = "warn"
[lints.clippy]
all = "warn"
pedantic = "warn"
nursery = "warn"
cargo = "warn"
unwrap_used = "warn"
expect_used = "warn"
panic = "warn"
todo = "warn"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"

127
PROMPT.md
View file

@ -5,6 +5,118 @@
--- ---
## ZERO TOLERANCE POLICY
**This project has the strictest code quality requirements possible.**
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
---
## ABSOLUTE PROHIBITIONS
```
❌ NEVER use #![allow()] or #[allow()] in source code to silence warnings
❌ NEVER use _ prefix for unused variables - DELETE the variable or USE it
❌ NEVER use .unwrap() - use ? or proper error handling
❌ NEVER use .expect() - use ? or proper error handling
❌ NEVER use panic!() or unreachable!() - handle all cases
❌ NEVER use todo!() or unimplemented!() - write real code
❌ NEVER leave unused imports - DELETE them
❌ NEVER leave dead code - DELETE it or IMPLEMENT it
❌ NEVER use approximate constants (3.14159) - use std::f64::consts::PI
❌ NEVER silence clippy in code - FIX THE CODE or configure in Cargo.toml
❌ NEVER add comments explaining what code does - code must be self-documenting
❌ NEVER use CDN links - all assets must be local
```
---
## CARGO.TOML LINT EXCEPTIONS
When a clippy lint has **technical false positives** that cannot be fixed in code,
disable it in `Cargo.toml` with a comment explaining why:
```toml
[lints.clippy]
# Disabled: has false positives for functions with mut self, heap types (Vec, String)
missing_const_for_fn = "allow"
# Disabled: Tauri commands require owned types (Window) that cannot be passed by reference
needless_pass_by_value = "allow"
# Disabled: transitive dependencies we cannot control
multiple_crate_versions = "allow"
```
**Approved exceptions:**
- `missing_const_for_fn` - false positives for `mut self`, heap types
- `needless_pass_by_value` - Tauri/framework requirements
- `multiple_crate_versions` - transitive dependencies
- `future_not_send` - when async traits require non-Send futures
---
## MANDATORY CODE PATTERNS
### Error Handling - Use `?` Operator
```rust
// ❌ WRONG
let value = something.unwrap();
let value = something.expect("msg");
// ✅ CORRECT
let value = something?;
let value = something.ok_or_else(|| Error::NotFound)?;
```
### Self Usage in Impl Blocks
```rust
// ❌ WRONG
impl MyStruct {
fn new() -> MyStruct { MyStruct { } }
}
// ✅ CORRECT
impl MyStruct {
fn new() -> Self { Self { } }
}
```
### Format Strings - Inline Variables
```rust
// ❌ WRONG
format!("Hello {}", name)
// ✅ CORRECT
format!("Hello {name}")
```
### Display vs ToString
```rust
// ❌ WRONG
impl ToString for MyType { }
// ✅ CORRECT
impl std::fmt::Display for MyType { }
```
### Derive Eq with PartialEq
```rust
// ❌ WRONG
#[derive(PartialEq)]
struct MyStruct { }
// ✅ CORRECT
#[derive(PartialEq, Eq)]
struct MyStruct { }
```
---
## Weekly Maintenance - EVERY MONDAY ## Weekly Maintenance - EVERY MONDAY
### Package Review Checklist ### Package Review Checklist
@ -435,13 +547,20 @@ ui/minimal/index.html # Minimal chat UI
## Remember ## Remember
- **Two LLM modes**: Execution (fazer) vs Review (conferir) - **ZERO WARNINGS** - Every clippy warning must be fixed
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
- **CARGO.TOML EXCEPTIONS OK** - Disable lints with false positives in Cargo.toml with comment
- **NO DEAD CODE** - Delete unused code, never prefix with _
- **NO UNWRAP/EXPECT** - Use ? operator or proper error handling
- **INLINE FORMAT ARGS** - format!("{name}") not format!("{}", name)
- **USE SELF** - In impl blocks, use Self not the type name
- **DERIVE EQ** - Always derive Eq with PartialEq
- **DISPLAY NOT TOSTRING** - Implement Display, not ToString
- **USE DIAGNOSTICS** - Use IDE diagnostics tool, never call cargo clippy directly
- **HTMX first**: Minimize JS, delegate to server - **HTMX first**: Minimize JS, delegate to server
- **Local assets**: No CDN, all vendor files local - **Local assets**: No CDN, all vendor files local
- **Dead code**: Never use _ prefix, implement real code
- **cargo audit**: Must pass with 0 warnings
- **No business logic**: All logic in botserver - **No business logic**: All logic in botserver
- **Feature gates**: Unused code never compiles
- **HTML responses**: Server returns fragments, not JSON - **HTML responses**: Server returns fragments, not JSON
- **Version**: Always 6.1.0 - do not change without approval - **Version**: Always 6.1.0 - do not change without approval
- **Theme system**: Use data-theme attribute on body, 6 themes available - **Theme system**: Use data-theme attribute on body, 6 themes available
- **Session Continuation**: When running out of context, create detailed summary: (1) what was done, (2) what remains, (3) specific files and line numbers, (4) exact next steps.

View file

@ -1,9 +1,9 @@
//! BotUI - General Bots Pure Web UI //! `BotUI` - General Bots Pure Web UI
//! //!
//! This crate provides the web UI layer for General Bots: //! This crate provides the web UI layer for General Bots:
//! - Serves static HTMX UI files (suite, minimal) //! - Serves static HTMX UI files (suite, minimal)
//! - Proxies API requests to botserver //! - Proxies API requests to botserver
//! - WebSocket support for real-time communication //! - `WebSocket` support for real-time communication
//! //!
//! For desktop/mobile native features, see the `botapp` crate which //! For desktop/mobile native features, see the `botapp` crate which
//! wraps this pure web UI with Tauri. //! wraps this pure web UI with Tauri.

View file

@ -1,3 +1,7 @@
//! `BotUI` main entry point
//!
//! Starts the web UI server for General Bots.
use log::info; use log::info;
use std::net::SocketAddr; use std::net::SocketAddr;
@ -21,14 +25,15 @@ fn get_port() -> u16 {
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
init_logging(); init_logging();
info!("BotUI {} starting...", env!("CARGO_PKG_VERSION")); let version = env!("CARGO_PKG_VERSION");
info!("BotUI {version} starting...");
let app = ui_server::configure_router(); let app = ui_server::configure_router();
let port = get_port(); let port = get_port();
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
info!("UI server listening on http://{}", addr); info!("UI server listening on http://{addr}");
axum::serve(listener, app) axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal()) .with_graceful_shutdown(shutdown_signal())
@ -40,24 +45,28 @@ async fn main() -> anyhow::Result<()> {
async fn shutdown_signal() { async fn shutdown_signal() {
let ctrl_c = async { let ctrl_c = async {
tokio::signal::ctrl_c() if let Err(e) = tokio::signal::ctrl_c().await {
.await log::error!("Failed to install Ctrl+C handler: {e}");
.expect("Failed to install Ctrl+C handler"); }
}; };
#[cfg(unix)] #[cfg(unix)]
let terminate = async { let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
.expect("Failed to install SIGTERM handler") Ok(mut signal) => {
.recv() signal.recv().await;
.await; }
Err(e) => {
log::error!("Failed to install SIGTERM handler: {e}");
}
}
}; };
#[cfg(not(unix))] #[cfg(not(unix))]
let terminate = std::future::pending::<()>(); let terminate = std::future::pending::<()>();
tokio::select! { tokio::select! {
_ = ctrl_c => info!("Received Ctrl+C, shutting down..."), () = ctrl_c => info!("Received Ctrl+C, shutting down..."),
_ = terminate => info!("Received SIGTERM, shutting down..."), () = terminate => info!("Received SIGTERM, shutting down..."),
} }
} }

View file

@ -1,4 +1,4 @@
//! Shared types and state management for BotUI //! Shared types and state management for `BotUI`
//! //!
//! This module provides shared application state and utilities //! This module provides shared application state and utilities
//! used across the UI server. //! used across the UI server.

View file

@ -1,22 +1,23 @@
//! Application state management //! Application state management
//! //!
//! This module contains the shared application state that is passed to all //! This module contains the shared application state that is passed to all
//! route handlers and provides access to the BotServer client. //! route handlers and provides access to the `BotServer` client.
use botlib::http_client::BotServerClient; use botlib::http_client::BotServerClient;
use std::sync::Arc; use std::sync::Arc;
/// Application state shared across all handlers /// Application state shared across all handlers
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct AppState { pub struct AppState {
/// HTTP client for communicating with BotServer /// HTTP client for communicating with `BotServer`
pub client: Arc<BotServerClient>, pub client: Arc<BotServerClient>,
} }
impl AppState { impl AppState {
/// Create a new application state /// Create a new application state
/// ///
/// Uses BOTSERVER_URL environment variable if set, otherwise defaults to localhost:8080 /// Uses `BOTSERVER_URL` environment variable if set, otherwise defaults to localhost:8080
#[must_use]
pub fn new() -> Self { pub fn new() -> Self {
let url = std::env::var("BOTSERVER_URL").ok(); let url = std::env::var("BOTSERVER_URL").ok();
Self { Self {
@ -24,7 +25,7 @@ impl AppState {
} }
} }
/// Check if the BotServer is healthy /// Check if the `BotServer` is healthy
pub async fn health_check(&self) -> bool { pub async fn health_check(&self) -> bool {
self.client.health_check().await self.client.health_check().await
} }

View file

@ -1,3 +1,7 @@
//! UI Server module for `BotUI`
//!
//! Handles HTTP routing, WebSocket proxying, and static file serving.
use axum::{ use axum::{
body::Body, body::Body,
extract::{ extract::{
@ -12,7 +16,7 @@ use axum::{
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info}; use log::{debug, error, info};
use serde::Deserialize; use serde::Deserialize;
use std::{fs, path::PathBuf}; use std::{fs, path::Path, path::PathBuf};
use tokio_tungstenite::{ use tokio_tungstenite::{
connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage, connect_async_tls_with_config, tungstenite::protocol::Message as TungsteniteMessage,
}; };
@ -52,7 +56,7 @@ pub async fn serve_minimal() -> impl IntoResponse {
match fs::read_to_string("ui/minimal/index.html") { match fs::read_to_string("ui/minimal/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)), Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
Err(e) => { Err(e) => {
error!("Failed to load minimal UI: {}", e); error!("Failed to load minimal UI: {e}");
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
[("content-type", "text/plain")], [("content-type", "text/plain")],
@ -66,7 +70,7 @@ pub async fn serve_suite() -> impl IntoResponse {
match fs::read_to_string("ui/suite/index.html") { match fs::read_to_string("ui/suite/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)), Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
Err(e) => { Err(e) => {
error!("Failed to load suite UI: {}", e); error!("Failed to load suite UI: {e}");
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
[("content-type", "text/plain")], [("content-type", "text/plain")],
@ -77,23 +81,24 @@ pub async fn serve_suite() -> impl IntoResponse {
} }
async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) { async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) {
match state.health_check().await { if state.health_check().await {
true => ( (
StatusCode::OK, StatusCode::OK,
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"status": "healthy", "status": "healthy",
"service": "botui", "service": "botui",
"mode": "web" "mode": "web"
})), })),
), )
false => ( } else {
(
StatusCode::SERVICE_UNAVAILABLE, StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"status": "unhealthy", "status": "unhealthy",
"service": "botui", "service": "botui",
"error": "botserver unreachable" "error": "botserver unreachable"
})), })),
), )
} }
} }
@ -121,8 +126,7 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
} }
} }
if path.starts_with("/apps/") { if let Some(after_apps) = path.strip_prefix("/apps/") {
let after_apps = &path[6..];
if let Some(end) = after_apps.find('/') { if let Some(end) = after_apps.find('/') {
return Some(after_apps[..end].to_string()); return Some(after_apps[..end].to_string());
} }
@ -139,18 +143,14 @@ async fn proxy_api(
let path = original_uri.path(); let path = original_uri.path();
let query = original_uri let query = original_uri
.query() .query()
.map(|q| format!("?{}", q)) .map_or_else(String::new, |q| format!("?{q}"));
.unwrap_or_default();
let method = req.method().clone(); let method = req.method().clone();
let headers = req.headers().clone(); let headers = req.headers().clone();
let app_context = extract_app_context(&headers, path); let app_context = extract_app_context(&headers, path);
let target_url = format!("{}{}{}", state.client.base_url(), path, query); let target_url = format!("{}{path}{query}", state.client.base_url());
debug!( debug!("Proxying {method} {path} to {target_url} (app: {app_context:?})");
"Proxying {} {} to {} (app: {:?})",
method, path, target_url, app_context
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
@ -158,7 +158,7 @@ async fn proxy_api(
.unwrap_or_else(|_| reqwest::Client::new()); .unwrap_or_else(|_| reqwest::Client::new());
let mut proxy_req = client.request(method.clone(), &target_url); let mut proxy_req = client.request(method.clone(), &target_url);
for (name, value) in headers.iter() { for (name, value) in &headers {
if name != "host" { if name != "host" {
if let Ok(v) = value.to_str() { if let Ok(v) = value.to_str() {
proxy_req = proxy_req.header(name.as_str(), v); proxy_req = proxy_req.header(name.as_str(), v);
@ -173,11 +173,11 @@ async fn proxy_api(
let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await { let body_bytes = match axum::body::to_bytes(req.into_body(), usize::MAX).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(e) => { Err(e) => {
error!("Failed to read request body: {}", e); error!("Failed to read request body: {e}");
return Response::builder() return build_error_response(
.status(StatusCode::INTERNAL_SERVER_ERROR) StatusCode::INTERNAL_SERVER_ERROR,
.body(Body::from("Failed to read request body")) "Failed to read request body",
.unwrap(); );
} }
}; };
@ -186,7 +186,27 @@ async fn proxy_api(
} }
match proxy_req.send().await { match proxy_req.send().await {
Ok(resp) => { Ok(resp) => build_proxy_response(resp).await,
Err(e) => {
error!("Proxy request failed: {e}");
build_error_response(StatusCode::BAD_GATEWAY, &format!("Proxy error: {e}"))
}
}
}
fn build_error_response(status: StatusCode, message: &str) -> Response<Body> {
Response::builder()
.status(status)
.body(Body::from(message.to_string()))
.unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Failed to build error response"))
.unwrap_or_default()
})
}
async fn build_proxy_response(resp: reqwest::Response) -> Response<Body> {
let status = resp.status(); let status = resp.status();
let headers = resp.headers().clone(); let headers = resp.headers().clone();
@ -194,32 +214,23 @@ async fn proxy_api(
Ok(body) => { Ok(body) => {
let mut response = Response::builder().status(status); let mut response = Response::builder().status(status);
for (name, value) in headers.iter() { for (name, value) in &headers {
response = response.header(name, value); response = response.header(name, value);
} }
response.body(Body::from(body)).unwrap_or_else(|_| { response.body(Body::from(body)).unwrap_or_else(|_| {
Response::builder() build_error_response(
.status(StatusCode::INTERNAL_SERVER_ERROR) StatusCode::INTERNAL_SERVER_ERROR,
.body(Body::from("Failed to build response")) "Failed to build response",
.unwrap() )
}) })
} }
Err(e) => { Err(e) => {
error!("Failed to read response body: {}", e); error!("Failed to read response body: {e}");
Response::builder() build_error_response(
.status(StatusCode::BAD_GATEWAY) StatusCode::BAD_GATEWAY,
.body(Body::from(format!("Failed to read response: {}", e))) &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()
} }
} }
} }
@ -244,6 +255,7 @@ async fn ws_proxy(
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params)) ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params))
} }
#[allow(clippy::too_many_lines)]
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) { async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
let backend_url = format!( let backend_url = format!(
"{}/ws?session_id={}&user_id={}", "{}/ws?session_id={}&user_id={}",
@ -256,13 +268,16 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
params.user_id params.user_id
); );
info!("Proxying WebSocket to: {}", backend_url); info!("Proxying WebSocket to: {backend_url}");
let tls_connector = native_tls::TlsConnector::builder() let Ok(tls_connector) = native_tls::TlsConnector::builder()
.danger_accept_invalid_certs(true) .danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true) .danger_accept_invalid_hostnames(true)
.build() .build()
.expect("Failed to build TLS connector"); else {
error!("Failed to build TLS connector");
return;
};
let connector = tokio_tungstenite::Connector::NativeTls(tls_connector); let connector = tokio_tungstenite::Connector::NativeTls(tls_connector);
@ -272,7 +287,7 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
let backend_socket = match backend_result { let backend_socket = match backend_result {
Ok((socket, _)) => socket, Ok((socket, _)) => socket,
Err(e) => { Err(e) => {
error!("Failed to connect to backend WebSocket: {}", e); error!("Failed to connect to backend WebSocket: {e}");
return; return;
} }
}; };
@ -350,14 +365,14 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
} }
} }
Ok(TungsteniteMessage::Close(_)) | Err(_) => break, Ok(TungsteniteMessage::Close(_)) | Err(_) => break,
_ => {} Ok(_) => {}
} }
} }
}; };
tokio::select! { tokio::select! {
_ = client_to_backend => info!("Client connection closed"), () = client_to_backend => info!("Client connection closed"),
_ = backend_to_client => info!("Backend connection closed"), () = backend_to_client => info!("Backend connection closed"),
} }
} }
@ -373,14 +388,14 @@ fn create_ui_router() -> Router<AppState> {
Router::new().fallback(any(proxy_api)) Router::new().fallback(any(proxy_api))
} }
fn add_static_routes(router: Router<AppState>, suite_path: &PathBuf) -> Router<AppState> { fn add_static_routes(router: Router<AppState>, suite_path: &Path) -> Router<AppState> {
let mut r = router; let mut r = router;
for dir in SUITE_DIRS { for dir in SUITE_DIRS {
let path = suite_path.join(dir); let path = suite_path.join(dir);
r = r r = r
.nest_service(&format!("/suite/{}", dir), ServeDir::new(path.clone())) .nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone()))
.nest_service(&format!("/{}", dir), ServeDir::new(path)); .nest_service(&format!("/{dir}"), ServeDir::new(path));
} }
r r