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"
description = "General Bots UI - Pure web interface"
license = "AGPL-3.0"
repository = "https://github.com/GeneralBots/BotServer"
keywords = ["chatbot", "ui", "web-interface", "general-bots"]
categories = ["web-programming", "gui"]
[dependencies.botlib]
path = "../botlib"
@ -49,3 +52,22 @@ tracing = "0.1"
urlencoding = "2.1"
uuid = { version = "1.11", features = ["serde", "v4"] }
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"

129
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
### Package Review Checklist
@ -435,13 +547,20 @@ ui/minimal/index.html # Minimal chat UI
## 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
- **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
- **Feature gates**: Unused code never compiles
- **HTML responses**: Server returns fragments, not JSON
- **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:
//! - Serves static HTMX UI files (suite, minimal)
//! - 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
//! 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 std::net::SocketAddr;
@ -21,14 +25,15 @@ fn get_port() -> u16 {
async fn main() -> anyhow::Result<()> {
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 port = get_port();
let addr = SocketAddr::from(([0, 0, 0, 0], port));
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)
.with_graceful_shutdown(shutdown_signal())
@ -40,24 +45,28 @@ async fn main() -> anyhow::Result<()> {
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
if let Err(e) = tokio::signal::ctrl_c().await {
log::error!("Failed to install Ctrl+C handler: {e}");
}
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("Failed to install SIGTERM handler")
.recv()
.await;
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut signal) => {
signal.recv().await;
}
Err(e) => {
log::error!("Failed to install SIGTERM handler: {e}");
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => info!("Received Ctrl+C, shutting down..."),
_ = terminate => info!("Received SIGTERM, shutting down..."),
() = ctrl_c => info!("Received Ctrl+C, 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
//! used across the UI server.

View file

@ -1,22 +1,23 @@
//! Application state management
//!
//! 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 std::sync::Arc;
/// Application state shared across all handlers
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct AppState {
/// HTTP client for communicating with BotServer
/// HTTP client for communicating with `BotServer`
pub client: Arc<BotServerClient>,
}
impl AppState {
/// 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 {
let url = std::env::var("BOTSERVER_URL").ok();
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 {
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::{
body::Body,
extract::{
@ -12,7 +16,7 @@ use axum::{
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info};
use serde::Deserialize;
use std::{fs, path::PathBuf};
use std::{fs, path::Path, path::PathBuf};
use tokio_tungstenite::{
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") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
Err(e) => {
error!("Failed to load minimal UI: {}", e);
error!("Failed to load minimal UI: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
[("content-type", "text/plain")],
@ -66,7 +70,7 @@ pub async fn serve_suite() -> impl IntoResponse {
match fs::read_to_string("ui/suite/index.html") {
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
Err(e) => {
error!("Failed to load suite UI: {}", e);
error!("Failed to load suite UI: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
[("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>) {
match state.health_check().await {
true => (
if state.health_check().await {
(
StatusCode::OK,
axum::Json(serde_json::json!({
"status": "healthy",
"service": "botui",
"mode": "web"
})),
),
false => (
)
} else {
(
StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({
"status": "unhealthy",
"service": "botui",
"error": "botserver unreachable"
})),
),
)
}
}
@ -121,8 +126,7 @@ fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<St
}
}
if path.starts_with("/apps/") {
let after_apps = &path[6..];
if let Some(after_apps) = path.strip_prefix("/apps/") {
if let Some(end) = after_apps.find('/') {
return Some(after_apps[..end].to_string());
}
@ -139,18 +143,14 @@ async fn proxy_api(
let path = original_uri.path();
let query = original_uri
.query()
.map(|q| format!("?{}", q))
.unwrap_or_default();
.map_or_else(String::new, |q| format!("?{q}"));
let method = req.method().clone();
let headers = req.headers().clone();
let app_context = extract_app_context(&headers, path);
let target_url = format!("{}{}{}", state.client.base_url(), path, query);
debug!(
"Proxying {} {} to {} (app: {:?})",
method, path, target_url, app_context
);
let target_url = format!("{}{path}{query}", state.client.base_url());
debug!("Proxying {method} {path} to {target_url} (app: {app_context:?})");
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
@ -158,7 +158,7 @@ async fn proxy_api(
.unwrap_or_else(|_| reqwest::Client::new());
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 let Ok(v) = value.to_str() {
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 {
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();
error!("Failed to read request body: {e}");
return build_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to read request body",
);
}
};
@ -186,40 +186,51 @@ async fn proxy_api(
}
match proxy_req.send().await {
Ok(resp) => {
let status = resp.status();
let headers = resp.headers().clone();
Ok(resp) => build_proxy_response(resp).await,
Err(e) => {
error!("Proxy request failed: {e}");
build_error_response(StatusCode::BAD_GATEWAY, &format!("Proxy error: {e}"))
}
}
}
match resp.bytes().await {
Ok(body) => {
let mut response = Response::builder().status(status);
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()
})
}
for (name, value) in headers.iter() {
response = response.header(name, value);
}
async fn build_proxy_response(resp: reqwest::Response) -> Response<Body> {
let status = resp.status();
let headers = resp.headers().clone();
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()
}
match resp.bytes().await {
Ok(body) => {
let mut response = Response::builder().status(status);
for (name, value) in &headers {
response = response.header(name, value);
}
response.body(Body::from(body)).unwrap_or_else(|_| {
build_error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to build response",
)
})
}
Err(e) => {
error!("Proxy request failed: {}", e);
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::from(format!("Proxy error: {}", e)))
.unwrap()
error!("Failed to read response body: {e}");
build_error_response(
StatusCode::BAD_GATEWAY,
&format!("Failed to read response: {e}"),
)
}
}
}
@ -244,6 +255,7 @@ async fn ws_proxy(
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) {
let backend_url = format!(
"{}/ws?session_id={}&user_id={}",
@ -256,13 +268,16 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
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_hostnames(true)
.build()
.expect("Failed to build TLS connector");
else {
error!("Failed to build TLS connector");
return;
};
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 {
Ok((socket, _)) => socket,
Err(e) => {
error!("Failed to connect to backend WebSocket: {}", e);
error!("Failed to connect to backend WebSocket: {e}");
return;
}
};
@ -350,14 +365,14 @@ async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQu
}
}
Ok(TungsteniteMessage::Close(_)) | Err(_) => break,
_ => {}
Ok(_) => {}
}
}
};
tokio::select! {
_ = client_to_backend => info!("Client connection closed"),
_ = backend_to_client => info!("Backend connection closed"),
() = client_to_backend => info!("Client 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))
}
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;
for dir in SUITE_DIRS {
let path = suite_path.join(dir);
r = r
.nest_service(&format!("/suite/{}", dir), ServeDir::new(path.clone()))
.nest_service(&format!("/{}", dir), ServeDir::new(path));
.nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone()))
.nest_service(&format!("/{dir}"), ServeDir::new(path));
}
r