Update UI with vendor libraries and PROMPT.md
This commit is contained in:
parent
8c17e13e37
commit
bd5b2c9481
21 changed files with 2047 additions and 841 deletions
45
Cargo.lock
generated
45
Cargo.lock
generated
|
|
@ -961,9 +961,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
|
|
@ -1228,9 +1228,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
|
|
@ -1293,9 +1293,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.28"
|
||||
version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
|
|
@ -1342,9 +1342,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.0"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
|
|
@ -2312,9 +2312,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.2"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
|
|
@ -2324,26 +2324,33 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.3"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.20.2"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
|
|
@ -2561,13 +2568,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.18.1"
|
||||
version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||
checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
|
|
@ -3080,9 +3087,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
|||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.5.40"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ axum = { version = "0.7.5", features = ["ws", "multipart", "macros"] }
|
|||
base64 = "0.22"
|
||||
bytes = "1.8"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
diesel = { version = "2.2", features = ["sqlite"] }
|
||||
diesel = { version = "2.1", features = ["sqlite"] }
|
||||
dirs = "5.0"
|
||||
env_logger = "0.11"
|
||||
futures = "0.3"
|
||||
|
|
|
|||
495
PROMPT.md
495
PROMPT.md
|
|
@ -1,82 +1,153 @@
|
|||
# BotUI - Architecture & Implementation Guide
|
||||
# BotUI Development Prompt Guide
|
||||
|
||||
## Status: ✅ COMPLETE - Zero Warnings, Real Code
|
||||
**Version:** 6.1.0
|
||||
**Purpose:** LLM context for BotUI development
|
||||
|
||||
---
|
||||
|
||||
## Project Overview
|
||||
|
||||
BotUI is a **dual-mode UI application** built in Rust that runs as either a desktop app (Tauri) or web server (Axum). All business logic is in **botserver** - BotUI is purely presentation + HTTP bridge.
|
||||
|
||||
### Workspace Position
|
||||
|
||||
```
|
||||
botui/ # THIS PROJECT - Web/Desktop UI
|
||||
botserver/ # Main server (business logic)
|
||||
botlib/ # Shared library (consumed here)
|
||||
botapp/ # Desktop wrapper (consumes botui)
|
||||
botbook/ # Documentation
|
||||
```
|
||||
|
||||
### What BotUI Provides
|
||||
|
||||
- **Web Mode**: Axum server serving HTML/CSS/JS UI on port 3000
|
||||
- **Desktop Mode**: Tauri native application with same UI
|
||||
- **HTTP Bridge**: Proxies all requests to botserver
|
||||
- **Local Assets**: All JS/CSS bundled locally (no CDN)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start BotServer
|
||||
cd ../botserver
|
||||
cargo run
|
||||
cd ../botserver && cargo run
|
||||
|
||||
# Terminal 2: Start BotUI (Web Mode)
|
||||
cd ../botui
|
||||
cargo run
|
||||
cd ../botui && cargo run
|
||||
# Visit http://localhost:3000
|
||||
|
||||
# OR Terminal 2: Start BotUI (Desktop Mode)
|
||||
cd ../botui
|
||||
# OR Desktop Mode
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual Modes
|
||||
- **Web Mode** (default): `cargo run`
|
||||
- Axum web server on port 3000
|
||||
- Serves HTML/CSS/JS UI
|
||||
- All requests proxy through HTTP to botserver
|
||||
|
||||
- **Desktop Mode**: `cargo tauri dev` or `cargo run --features desktop`
|
||||
- Tauri native application
|
||||
- Same UI runs in desktop window
|
||||
- Tauri commands proxy through HTTP to botserver
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| Web | `cargo run` | Axum server on port 3000 |
|
||||
| Desktop | `cargo tauri dev` | Tauri native window |
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
botui/src/
|
||||
├── main.rs # Entry point - detects mode and routes to web_main or stays for desktop
|
||||
├── lib.rs # Feature-gated module exports
|
||||
├── http_client.rs # Generic HTTP client wrapper (web-only)
|
||||
src/
|
||||
├── main.rs # Entry point - mode detection
|
||||
├── lib.rs # Feature-gated module exports
|
||||
├── http_client.rs # HTTP wrapper for botserver (web-only)
|
||||
├── ui_server/
|
||||
│ └── mod.rs # Axum router + UI serving (web-only)
|
||||
├── web/
|
||||
│ ├── mod.rs # Data structures/DTOs
|
||||
│ └── health_handlers.rs # Health check routes (web-only)
|
||||
│ └── mod.rs # Axum router + UI serving (web-only)
|
||||
├── desktop/
|
||||
│ ├── mod.rs # Desktop module organization
|
||||
│ ├── drive.rs # File operations via Tauri commands
|
||||
│ ├── tray.rs # System tray infrastructure
|
||||
│ └── stream.rs # Streaming operations
|
||||
│ ├── mod.rs # Desktop module organization
|
||||
│ ├── drive.rs # File operations via Tauri
|
||||
│ ├── tray.rs # System tray infrastructure
|
||||
│ └── stream.rs # Streaming operations
|
||||
└── shared/
|
||||
└── state.rs # Shared application state
|
||||
└── state.rs # Shared application state
|
||||
|
||||
ui/
|
||||
├── suite/ # Main UI (HTML/CSS/JS)
|
||||
│ ├── js/vendor/ # Local JS libraries (htmx, marked, etc.)
|
||||
│ └── css/ # Stylesheets
|
||||
└── minimal/ # Minimal chat UI
|
||||
└── js/vendor/ # Local JS libraries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Gating
|
||||
|
||||
Code is compiled based on features:
|
||||
|
||||
```rust
|
||||
#[cfg(feature = "desktop")] // Only compiles for desktop build
|
||||
#[cfg(feature = "desktop")] // Desktop build only
|
||||
pub mod desktop;
|
||||
|
||||
#[cfg(not(feature = "desktop"))] // Only compiles for web build
|
||||
#[cfg(not(feature = "desktop"))] // Web build only
|
||||
pub mod http_client;
|
||||
```
|
||||
|
||||
Build commands:
|
||||
```bash
|
||||
cargo build # Web mode (default)
|
||||
cargo build --features desktop # Desktop mode
|
||||
cargo tauri build # Optimized desktop build
|
||||
cargo build # Web mode (default)
|
||||
cargo build --features desktop # Desktop mode
|
||||
cargo tauri build # Optimized desktop build
|
||||
```
|
||||
|
||||
## HTTP Client (`src/http_client.rs`)
|
||||
---
|
||||
|
||||
Generic wrapper for calling botserver APIs:
|
||||
## Code Generation Rules
|
||||
|
||||
### CRITICAL REQUIREMENTS
|
||||
|
||||
```
|
||||
- BotUI = Presentation + HTTP bridge ONLY
|
||||
- All business logic goes in botserver
|
||||
- No code duplication between layers
|
||||
- Feature gates eliminate unused code paths
|
||||
- Zero warnings - feature gating prevents dead code
|
||||
- All JS/CSS must be local (no CDN)
|
||||
```
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Minimize Code** - Only presentation and HTTP bridging
|
||||
2. **Feature Gating** - Desktop code doesn't compile in web mode
|
||||
3. **HTTP Communication** - All botserver calls through BotServerClient
|
||||
4. **Local Assets** - All vendor JS in ui/*/js/vendor/
|
||||
|
||||
---
|
||||
|
||||
## Local JS/CSS Vendor Files
|
||||
|
||||
All external libraries are bundled locally:
|
||||
|
||||
```
|
||||
ui/suite/js/vendor/
|
||||
├── htmx.min.js # HTMX 1.9.10
|
||||
├── htmx-ws.js # HTMX WebSocket extension
|
||||
├── htmx-json-enc.js # HTMX JSON encoding
|
||||
├── marked.min.js # Markdown parser
|
||||
├── gsap.min.js # Animation library
|
||||
├── alpinejs.min.js # Alpine.js reactivity
|
||||
└── livekit-client.umd.min.js # LiveKit video
|
||||
```
|
||||
|
||||
**NEVER use CDN URLs** - always reference local vendor files:
|
||||
```html
|
||||
<!-- CORRECT -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
|
||||
<!-- WRONG - DO NOT USE -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP Client
|
||||
|
||||
```rust
|
||||
pub struct BotServerClient {
|
||||
|
|
@ -87,320 +158,70 @@ pub struct BotServerClient {
|
|||
impl BotServerClient {
|
||||
pub async fn get<T: Deserialize>(&self, endpoint: &str) -> Result<T, String>
|
||||
pub async fn post<T, R>(&self, endpoint: &str, body: &T) -> Result<R, String>
|
||||
pub async fn put<T, R>(&self, endpoint: &str, body: &T) -> Result<R, String>
|
||||
pub async fn delete<T>(&self, endpoint: &str) -> Result<T, String>
|
||||
pub async fn health_check(&self) -> bool
|
||||
}
|
||||
```
|
||||
|
||||
Usage:
|
||||
```rust
|
||||
let client = BotServerClient::new(None); // Uses BOTSERVER_URL env var
|
||||
let result: MyType = client.get("/api/endpoint").await?;
|
||||
```
|
||||
|
||||
## Web Server (`src/ui_server/mod.rs`)
|
||||
|
||||
Axum router that:
|
||||
- Serves UI files from `ui/suite/`
|
||||
- Provides health check endpoints
|
||||
- Maintains HTTP client state for calling botserver
|
||||
|
||||
Routes:
|
||||
- `/` - Root (serves index.html)
|
||||
- `/health` - Health check with botserver connectivity
|
||||
- `/api/health` - API health status
|
||||
- `/suite/*` - Suite UI and assets
|
||||
- `/*` - Fallback to minimal UI
|
||||
|
||||
## Desktop Mode (`src/desktop/`)
|
||||
|
||||
### Tauri Commands (`drive.rs`)
|
||||
|
||||
Functions marked with `#[tauri::command]` are callable from JavaScript:
|
||||
|
||||
```rust
|
||||
#[tauri::command]
|
||||
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file(window: Window, src_path: String, dest_path: String) -> Result<(), String>
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_folder(path: String, name: String) -> Result<(), String>
|
||||
```
|
||||
|
||||
### System Tray (`tray.rs`)
|
||||
|
||||
Infrastructure for system tray integration:
|
||||
- `TrayManager` - Main tray controller
|
||||
- `RunningMode` - Desktop/Server/Client modes
|
||||
- `ServiceMonitor` - Monitors service health
|
||||
- `ServiceStatus` - Service status tracking
|
||||
|
||||
## Communication Flow
|
||||
|
||||
### Web Mode
|
||||
```
|
||||
Browser UI (HTML/CSS/JS)
|
||||
↓ HTTP
|
||||
Axum Route Handler
|
||||
↓ HTTP
|
||||
BotServerClient
|
||||
↓ HTTP
|
||||
BotServer API
|
||||
↓
|
||||
Business Logic + Database
|
||||
```
|
||||
|
||||
### Desktop Mode
|
||||
```
|
||||
Tauri UI (HTML/CSS/JS)
|
||||
↓ Tauri IPC
|
||||
Rust #[tauri::command]
|
||||
↓ HTTP
|
||||
BotServerClient (future)
|
||||
↓ HTTP
|
||||
BotServer API (future)
|
||||
↓
|
||||
Business Logic + Database
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# BotServer location (default: http://localhost:8081)
|
||||
export BOTSERVER_URL=http://localhost:8081
|
||||
|
||||
# Logging level (default: info)
|
||||
export RUST_LOG=debug
|
||||
|
||||
# Rust backtrace
|
||||
export RUST_BACKTRACE=1
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. Minimize Code in BotUI
|
||||
- BotUI = Presentation + HTTP bridge only
|
||||
- All business logic in botserver
|
||||
- No code duplication between layers
|
||||
|
||||
### 2. Real Code, Zero Dead Code
|
||||
- ✅ Feature gates eliminate unused code paths
|
||||
- ✅ Desktop code doesn't compile in web mode
|
||||
- ✅ Web code doesn't compile in desktop mode
|
||||
- ✅ Result: Zero warnings, only real code compiled
|
||||
|
||||
### 3. Feature Gating
|
||||
- `#[cfg(feature = "desktop")]` - Desktop-only code
|
||||
- `#[cfg(not(feature = "desktop"))]` - Web-only code
|
||||
- Unused code never compiles, never produces warnings
|
||||
|
||||
### 4. HTTP Communication
|
||||
- All botserver calls go through `BotServerClient`
|
||||
- Single HTTP client shared across application state
|
||||
- Error handling and health checks built-in
|
||||
|
||||
## File Responsibilities
|
||||
|
||||
### Keep Real, Active Code
|
||||
|
||||
✅ `src/main.rs` (45 lines)
|
||||
- Mode detection (desktop vs web)
|
||||
- Calls appropriate initialization
|
||||
|
||||
✅ `src/http_client.rs` (156 lines)
|
||||
- Generic HTTP client for botserver
|
||||
- GET/POST/PUT/DELETE methods
|
||||
- Error handling & health checks
|
||||
|
||||
✅ `src/ui_server/mod.rs` (135 lines)
|
||||
- Axum router configuration
|
||||
- UI serving
|
||||
- Health check endpoints
|
||||
- HTTP client state management
|
||||
|
||||
✅ `src/desktop/drive.rs` (82 lines)
|
||||
- Tauri file dialog commands
|
||||
- File operations
|
||||
- Actually called from UI via IPC
|
||||
|
||||
✅ `src/desktop/tray.rs` (163 lines)
|
||||
- System tray infrastructure
|
||||
- Service monitoring
|
||||
- Running mode tracking
|
||||
|
||||
✅ `src/web/mod.rs` (51 lines)
|
||||
- Data structures (DTOs)
|
||||
- Request/Response types
|
||||
- Used by UI and API routes
|
||||
|
||||
✅ `ui/suite/` (HTML/CSS/JS)
|
||||
- Desktop and web UI
|
||||
- Works in both modes
|
||||
- Calls Tauri commands (desktop) or HTTP (web)
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Build web mode
|
||||
cargo build
|
||||
|
||||
# Build desktop mode
|
||||
cargo build --features desktop
|
||||
|
||||
# Run tests
|
||||
cargo test
|
||||
|
||||
# Run web server
|
||||
cargo run
|
||||
# Visit http://localhost:3000
|
||||
|
||||
# Run desktop app
|
||||
cargo tauri dev
|
||||
```
|
||||
---
|
||||
|
||||
## Adding Features
|
||||
|
||||
### Process
|
||||
|
||||
1. Add business logic to **botserver** first
|
||||
2. Create REST API endpoint in botserver
|
||||
3. Add HTTP wrapper in BotUI (`http_client` or specific handler)
|
||||
3. Add HTTP wrapper in BotUI
|
||||
4. Add UI in `ui/suite/`
|
||||
5. For desktop-specific features: Add Tauri command in `src/desktop/`
|
||||
5. For desktop-specific: Add Tauri command in `src/desktop/`
|
||||
|
||||
### Example: Add File Upload
|
||||
### Desktop Tauri Command
|
||||
|
||||
**BotServer**:
|
||||
```rust
|
||||
// botserver/src/drive/mod.rs
|
||||
#[post("/api/drive/upload")]
|
||||
pub async fn upload_file(/* ... */) -> impl IntoResponse { /* ... */ }
|
||||
```
|
||||
|
||||
**BotUI Web**:
|
||||
```rust
|
||||
// botui/src/web/drive_handlers.rs
|
||||
#[post("/api/drive/upload")]
|
||||
pub async fn upload_handler(State(client): State<Arc<BotServerClient>>, body) -> Json<Response> {
|
||||
let result = client.post("/api/drive/upload", &body).await?;
|
||||
Json(result)
|
||||
}
|
||||
```
|
||||
|
||||
**BotUI Desktop**:
|
||||
```rust
|
||||
// botui/src/desktop/drive.rs
|
||||
#[tauri::command]
|
||||
pub async fn upload_file(window: Window, path: String) -> Result<UploadResult, String> {
|
||||
// Use file dialog via Tauri
|
||||
let file = tauri::api::dialog::FileDialogBuilder::new()
|
||||
.pick_file()
|
||||
.await?;
|
||||
// In future: call botserver via HTTP
|
||||
Ok(UploadResult { /* ... */ })
|
||||
pub fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
**UI**:
|
||||
```javascript
|
||||
// ui/suite/js/drive.js
|
||||
async function uploadFile() {
|
||||
const result = await invoke('upload_file', { path: '/home/user' });
|
||||
// Or in web mode:
|
||||
// const result = await fetch('/api/drive/upload', { method: 'POST' });
|
||||
}
|
||||
```
|
||||
|
||||
## Compilation Strategy
|
||||
|
||||
### Web Build (Default)
|
||||
- Compiles: `main.rs`, `ui_server/`, `http_client.rs`, `web/`, UI
|
||||
- Excludes: `desktop/` modules (feature-gated out)
|
||||
- Result: Small, fast web server
|
||||
|
||||
### Desktop Build
|
||||
- Compiles: `main.rs`, `desktop/`, Tauri dependencies
|
||||
- Excludes: `http_client.rs`, `ui_server/` (feature-gated out)
|
||||
- Result: Native desktop application
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
- **Web Mode**:
|
||||
- Startup: ~100ms
|
||||
- Memory: ~50MB (Axum + dependencies)
|
||||
- Connections: Persistent HTTP to botserver
|
||||
|
||||
- **Desktop Mode**:
|
||||
- Startup: ~500ms (Tauri initialization)
|
||||
- Memory: ~100MB (Chromium-based)
|
||||
- Connections: Same app process as UI
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- No credentials stored in BotUI
|
||||
- All auth handled by botserver
|
||||
- HTTP calls validated
|
||||
- CORS configured in botserver
|
||||
- Errors don't leak sensitive data
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Cannot connect to botserver"
|
||||
```bash
|
||||
curl http://localhost:8081/health
|
||||
# Should return 200 OK
|
||||
```
|
||||
|
||||
### "Compilation error"
|
||||
```bash
|
||||
cargo clean
|
||||
cargo build
|
||||
```
|
||||
|
||||
### "Module not found"
|
||||
Ensure you're using correct feature flags:
|
||||
```bash
|
||||
cargo build --features desktop # For desktop
|
||||
cargo build # For web (default)
|
||||
```
|
||||
|
||||
### "Port already in use"
|
||||
```bash
|
||||
lsof -i :3000
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
## Project Statistics
|
||||
|
||||
- **Total Lines**: ~600 lines of Rust
|
||||
- **Modules**: 8 core modules
|
||||
- **Warnings**: 0 (feature gating eliminates all dead code)
|
||||
- **Features**: Dual-mode, feature-gated compilation
|
||||
- **Build Time**: ~10s (web), ~20s (desktop)
|
||||
|
||||
## References
|
||||
|
||||
- **BotServer**: `../botserver/` - All business logic
|
||||
- **UI**: `ui/suite/` - HTML/CSS/JavaScript
|
||||
- **Docs**: `../botserver/docs/` - API documentation
|
||||
- **Tauri**: https://tauri.app - Desktop framework
|
||||
- **Axum**: https://docs.rs/axum - Web framework
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] WebSocket support for real-time updates
|
||||
- [ ] Desktop: HTTP client for botserver calls
|
||||
- [ ] Offline mode with local caching
|
||||
- [ ] Mobile UI variant
|
||||
- [ ] API documentation generation
|
||||
- [ ] Performance profiling
|
||||
- [ ] E2E testing suite
|
||||
|
||||
---
|
||||
|
||||
**Status**: Production-ready dual-mode application
|
||||
**Warnings**: 0 (feature-gated implementation)
|
||||
**Test Coverage**: Ready for expansion
|
||||
**Last Updated**: 2024
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
BOTSERVER_URL=http://localhost:8081 # BotServer location
|
||||
RUST_LOG=debug # Logging level
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| axum | 0.7.5 | Web framework |
|
||||
| reqwest | 0.12 | HTTP client |
|
||||
| tokio | 1.41 | Async runtime |
|
||||
| askama | 0.12 | Templates |
|
||||
| diesel | 2.1 | Database (sqlite) |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo build # Web mode
|
||||
cargo build --features desktop # Desktop mode
|
||||
cargo test
|
||||
cargo run # Start web server
|
||||
cargo tauri dev # Start desktop app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
- **No business logic** - only presentation
|
||||
- **No CDN** - all assets local
|
||||
- **Feature gate** - unused code never compiles
|
||||
- **Zero warnings** - clean compilation
|
||||
- **HTTP bridge** - all data from botserver
|
||||
|
|
@ -15,11 +15,12 @@
|
|||
CONTEXT_CHANGE: 5,
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<!-- Local Libraries (no external CDN dependencies) -->
|
||||
<script src="js/vendor/gsap.min.js"></script>
|
||||
<script src="js/vendor/livekit-client.umd.min.js"></script>
|
||||
<script src="js/vendor/marked.min.js"></script>
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&display=swap");
|
||||
/* Using system fonts - no external font loading */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--fg: #000000;
|
||||
|
|
|
|||
5
ui/minimal/js/vendor/alpinejs.min.js
vendored
Normal file
5
ui/minimal/js/vendor/alpinejs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
ui/minimal/js/vendor/gsap.min.js
vendored
Normal file
11
ui/minimal/js/vendor/gsap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
ui/minimal/js/vendor/htmx-json-enc.js
vendored
Normal file
12
ui/minimal/js/vendor/htmx-json-enc.js
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
htmx.defineExtension('json-enc', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === "htmx:configRequest") {
|
||||
evt.detail.headers['Content-Type'] = "application/json";
|
||||
}
|
||||
},
|
||||
|
||||
encodeParameters : function(xhr, parameters, elt) {
|
||||
xhr.overrideMimeType('text/json');
|
||||
return (JSON.stringify(parameters));
|
||||
}
|
||||
});
|
||||
477
ui/minimal/js/vendor/htmx-ws.js
vendored
Normal file
477
ui/minimal/js/vendor/htmx-ws.js
vendored
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("ws", {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
|
||||
// Store reference to internal API
|
||||
api = apiRef;
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket;
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = "full-jitter";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
|
||||
switch (name) {
|
||||
|
||||
// Try to close the socket when elements are removed
|
||||
case "htmx:beforeCleanupElement":
|
||||
|
||||
var internalData = api.getInternalData(evt.target)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
}
|
||||
return;
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case "htmx:beforeProcessNode":
|
||||
var parent = evt.target;
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||
ensureWebSocket(child)
|
||||
});
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||
ensureWebSocketSend(child)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||
|
||||
if (wssSource == null || wssSource === "") {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||
if (legacySource == null) {
|
||||
return;
|
||||
} else {
|
||||
wssSource = legacySource;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf("/") === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = "wss://" + base_part + wssSource;
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = "ws://" + base_part + wssSource;
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
});
|
||||
|
||||
socketWrapper.addEventListener('message', function (event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var response = event.data;
|
||||
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function (extension) {
|
||||
response = extension.transformResponse(response, null, socketElt);
|
||||
});
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt);
|
||||
var fragment = api.makeFragment(response);
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children);
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks);
|
||||
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
});
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function (event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler);
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
|
||||
this.events[event].push(handler);
|
||||
},
|
||||
|
||||
sendImmediately: function (message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message);
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function (message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt);
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function () {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||
this.messageQueue.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function () {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc();
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = function (e) {
|
||||
wrapper.retryCount = 0;
|
||||
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||
wrapper.handleQueuedMessages();
|
||||
}
|
||||
|
||||
socket.onclose = function (e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||
setTimeout(function () {
|
||||
wrapper.retryCount += 1;
|
||||
wrapper.init();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||
};
|
||||
|
||||
socket.onerror = function (e) {
|
||||
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||
maybeCloseWebSocketSource(socketElt);
|
||||
};
|
||||
|
||||
var events = this.events;
|
||||
Object.keys(events).forEach(function (k) {
|
||||
events[k].forEach(function (e) {
|
||||
socket.addEventListener(k, e);
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
close: function () {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init();
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt);
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||
var results = api.getInputValues(sendElt, 'post');
|
||||
var errors = results.errors;
|
||||
var rawParameters = results.values;
|
||||
var expressionVars = api.getExpressionVars(sendElt);
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers: headers,
|
||||
errors: errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
};
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody;
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters);
|
||||
if (sendConfig.headers)
|
||||
toSend['HEADERS'] = headers;
|
||||
body = JSON.stringify(toSend);
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt);
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay;
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount);
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6);
|
||||
var maxDelay = 1000 * Math.pow(2, exp);
|
||||
return maxDelay * Math.random();
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, []);
|
||||
sock.binaryType = htmx.config.wsBinaryType;
|
||||
return sock;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
1
ui/minimal/js/vendor/htmx.min.js
vendored
Normal file
1
ui/minimal/js/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
ui/minimal/js/vendor/livekit-client.umd.min.js
vendored
Normal file
8
ui/minimal/js/vendor/livekit-client.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
69
ui/minimal/js/vendor/marked.min.js
vendored
Normal file
69
ui/minimal/js/vendor/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1148
ui/suite/base.html
1148
ui/suite/base.html
File diff suppressed because it is too large
Load diff
|
|
@ -13,14 +13,11 @@
|
|||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
|
||||
<!-- External Libraries -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script
|
||||
defer
|
||||
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
|
||||
></script>
|
||||
<!-- Local Libraries (no external CDN dependencies) -->
|
||||
<script src="js/vendor/gsap.min.js"></script>
|
||||
<script src="js/vendor/livekit-client.umd.min.js"></script>
|
||||
<script src="js/vendor/marked.min.js"></script>
|
||||
<script defer src="js/vendor/alpinejs.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@
|
|||
<link rel="stylesheet" href="css/apps-extended.css" />
|
||||
<link rel="stylesheet" href="css/components.css" />
|
||||
|
||||
<!-- External Libraries -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
|
||||
<script src="https://unpkg.com/htmx.org/dist/ext/json-enc.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<!-- 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>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
5
ui/suite/js/vendor/alpinejs.min.js
vendored
Normal file
5
ui/suite/js/vendor/alpinejs.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
11
ui/suite/js/vendor/gsap.min.js
vendored
Normal file
11
ui/suite/js/vendor/gsap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
ui/suite/js/vendor/htmx-json-enc.js
vendored
Normal file
12
ui/suite/js/vendor/htmx-json-enc.js
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
htmx.defineExtension('json-enc', {
|
||||
onEvent: function (name, evt) {
|
||||
if (name === "htmx:configRequest") {
|
||||
evt.detail.headers['Content-Type'] = "application/json";
|
||||
}
|
||||
},
|
||||
|
||||
encodeParameters : function(xhr, parameters, elt) {
|
||||
xhr.overrideMimeType('text/json');
|
||||
return (JSON.stringify(parameters));
|
||||
}
|
||||
});
|
||||
477
ui/suite/js/vendor/htmx-ws.js
vendored
Normal file
477
ui/suite/js/vendor/htmx-ws.js
vendored
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("ws", {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
|
||||
// Store reference to internal API
|
||||
api = apiRef;
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket;
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = "full-jitter";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
|
||||
switch (name) {
|
||||
|
||||
// Try to close the socket when elements are removed
|
||||
case "htmx:beforeCleanupElement":
|
||||
|
||||
var internalData = api.getInternalData(evt.target)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
}
|
||||
return;
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case "htmx:beforeProcessNode":
|
||||
var parent = evt.target;
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||
ensureWebSocket(child)
|
||||
});
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||
ensureWebSocketSend(child)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||
|
||||
if (wssSource == null || wssSource === "") {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||
if (legacySource == null) {
|
||||
return;
|
||||
} else {
|
||||
wssSource = legacySource;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf("/") === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = "wss://" + base_part + wssSource;
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = "ws://" + base_part + wssSource;
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
});
|
||||
|
||||
socketWrapper.addEventListener('message', function (event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var response = event.data;
|
||||
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function (extension) {
|
||||
response = extension.transformResponse(response, null, socketElt);
|
||||
});
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt);
|
||||
var fragment = api.makeFragment(response);
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children);
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks);
|
||||
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
});
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function (event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler);
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
|
||||
this.events[event].push(handler);
|
||||
},
|
||||
|
||||
sendImmediately: function (message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message);
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function (message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt);
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function () {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||
this.messageQueue.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function () {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc();
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = function (e) {
|
||||
wrapper.retryCount = 0;
|
||||
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||
wrapper.handleQueuedMessages();
|
||||
}
|
||||
|
||||
socket.onclose = function (e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||
setTimeout(function () {
|
||||
wrapper.retryCount += 1;
|
||||
wrapper.init();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||
};
|
||||
|
||||
socket.onerror = function (e) {
|
||||
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||
maybeCloseWebSocketSource(socketElt);
|
||||
};
|
||||
|
||||
var events = this.events;
|
||||
Object.keys(events).forEach(function (k) {
|
||||
events[k].forEach(function (e) {
|
||||
socket.addEventListener(k, e);
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
close: function () {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init();
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt);
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||
var results = api.getInputValues(sendElt, 'post');
|
||||
var errors = results.errors;
|
||||
var rawParameters = results.values;
|
||||
var expressionVars = api.getExpressionVars(sendElt);
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers: headers,
|
||||
errors: errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
};
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody;
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters);
|
||||
if (sendConfig.headers)
|
||||
toSend['HEADERS'] = headers;
|
||||
body = JSON.stringify(toSend);
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt);
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay;
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount);
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6);
|
||||
var maxDelay = 1000 * Math.pow(2, exp);
|
||||
return maxDelay * Math.random();
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, []);
|
||||
sock.binaryType = htmx.config.wsBinaryType;
|
||||
return sock;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
1
ui/suite/js/vendor/htmx.min.js
vendored
Normal file
1
ui/suite/js/vendor/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
8
ui/suite/js/vendor/livekit-client.umd.min.js
vendored
Normal file
8
ui/suite/js/vendor/livekit-client.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
69
ui/suite/js/vendor/marked.min.js
vendored
Normal file
69
ui/suite/js/vendor/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue