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"
|
||||
|
|
|
|||
475
PROMPT.md
475
PROMPT.md
|
|
@ -1,69 +1,92 @@
|
|||
# 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
|
||||
src/
|
||||
├── main.rs # Entry point - mode detection
|
||||
├── lib.rs # Feature-gated module exports
|
||||
├── http_client.rs # Generic HTTP client wrapper (web-only)
|
||||
├── 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)
|
||||
├── desktop/
|
||||
│ ├── mod.rs # Desktop module organization
|
||||
│ ├── drive.rs # File operations via Tauri commands
|
||||
│ ├── drive.rs # File operations via Tauri
|
||||
│ ├── tray.rs # System tray infrastructure
|
||||
│ └── stream.rs # Streaming operations
|
||||
└── shared/
|
||||
└── 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;
|
||||
```
|
||||
|
||||
|
|
@ -74,9 +97,57 @@ 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
|
|
@ -1,16 +1,16 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}General Bots Suite{% endblock %}</title>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10/dist/ext/ws.js"></script>
|
||||
<!-- HTMX (local) -->
|
||||
<script src="/js/vendor/htmx.min.js"></script>
|
||||
<script src="/js/vendor/htmx-ws.js"></script>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/css/app.css">
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
|
||||
<style>
|
||||
:root {
|
||||
|
|
@ -47,7 +47,9 @@
|
|||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
|
|
@ -109,7 +111,9 @@
|
|||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -246,7 +250,9 @@
|
|||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
|
|
@ -273,10 +279,18 @@
|
|||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.notification.success { border-left: 4px solid var(--success); }
|
||||
.notification.error { border-left: 4px solid var(--error); }
|
||||
.notification.warning { border-left: 4px solid var(--warning); }
|
||||
.notification.info { border-left: 4px solid var(--info); }
|
||||
.notification.success {
|
||||
border-left: 4px solid var(--success);
|
||||
}
|
||||
.notification.error {
|
||||
border-left: 4px solid var(--error);
|
||||
}
|
||||
.notification.warning {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
.notification.info {
|
||||
border-left: 4px solid var(--info);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
|
|
@ -316,8 +330,8 @@
|
|||
</style>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
|
|
@ -330,8 +344,18 @@
|
|||
<div class="header-right">
|
||||
<!-- Apps Menu -->
|
||||
<div class="apps-menu">
|
||||
<button class="header-btn" id="apps-btn" aria-label="Applications" aria-expanded="false">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||
<button
|
||||
class="header-btn"
|
||||
id="apps-btn"
|
||||
aria-label="Applications"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<circle cx="5" cy="5" r="2"></circle>
|
||||
<circle cx="12" cy="5" r="2"></circle>
|
||||
<circle cx="19" cy="5" r="2"></circle>
|
||||
|
|
@ -346,40 +370,202 @@
|
|||
<nav class="apps-dropdown" id="apps-dropdown" role="menu">
|
||||
<div class="apps-dropdown-title">Applications</div>
|
||||
<div class="apps-grid">
|
||||
<a href="#chat" class="app-item" role="menuitem" hx-get="/chat/chat.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #3b82f6, #1d4ed8);">💬</div>
|
||||
<a
|
||||
href="#chat"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/chat/chat.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#3b82f6,
|
||||
#1d4ed8
|
||||
);
|
||||
"
|
||||
>
|
||||
💬
|
||||
</div>
|
||||
<span>Chat</span>
|
||||
</a>
|
||||
<a href="#drive" class="app-item" role="menuitem" hx-get="/drive/index.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #f59e0b, #d97706);">📁</div>
|
||||
<a
|
||||
href="#drive"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/drive/index.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#f59e0b,
|
||||
#d97706
|
||||
);
|
||||
"
|
||||
>
|
||||
📁
|
||||
</div>
|
||||
<span>Drive</span>
|
||||
</a>
|
||||
<a href="#tasks" class="app-item" role="menuitem" hx-get="/tasks/tasks.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #22c55e, #16a34a);">✓</div>
|
||||
<a
|
||||
href="#tasks"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/tasks/tasks.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#22c55e,
|
||||
#16a34a
|
||||
);
|
||||
"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
<span>Tasks</span>
|
||||
</a>
|
||||
<a href="#mail" class="app-item" role="menuitem" hx-get="/mail/mail.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #ef4444, #dc2626);">✉️</div>
|
||||
<a
|
||||
href="#mail"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/mail/mail.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#ef4444,
|
||||
#dc2626
|
||||
);
|
||||
"
|
||||
>
|
||||
✉️
|
||||
</div>
|
||||
<span>Mail</span>
|
||||
</a>
|
||||
<a href="#calendar" class="app-item" role="menuitem" hx-get="/calendar/calendar.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #a855f7, #7c3aed);">📅</div>
|
||||
<a
|
||||
href="#calendar"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/calendar/calendar.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#a855f7,
|
||||
#7c3aed
|
||||
);
|
||||
"
|
||||
>
|
||||
📅
|
||||
</div>
|
||||
<span>Calendar</span>
|
||||
</a>
|
||||
<a href="#meet" class="app-item" role="menuitem" hx-get="/meet/meet.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #06b6d4, #0891b2);">🎥</div>
|
||||
<a
|
||||
href="#meet"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/meet/meet.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#06b6d4,
|
||||
#0891b2
|
||||
);
|
||||
"
|
||||
>
|
||||
🎥
|
||||
</div>
|
||||
<span>Meet</span>
|
||||
</a>
|
||||
<a href="#paper" class="app-item" role="menuitem" hx-get="/paper/paper.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #eab308, #ca8a04);">📝</div>
|
||||
<a
|
||||
href="#paper"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/paper/paper.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#eab308,
|
||||
#ca8a04
|
||||
);
|
||||
"
|
||||
>
|
||||
📝
|
||||
</div>
|
||||
<span>Paper</span>
|
||||
</a>
|
||||
<a href="#research" class="app-item" role="menuitem" hx-get="/research/research.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #ec4899, #db2777);">🔍</div>
|
||||
<a
|
||||
href="#research"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/research/research.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#ec4899,
|
||||
#db2777
|
||||
);
|
||||
"
|
||||
>
|
||||
🔍
|
||||
</div>
|
||||
<span>Research</span>
|
||||
</a>
|
||||
<a href="#analytics" class="app-item" role="menuitem" hx-get="/analytics/analytics.html" hx-target="#main-content" hx-push-url="true">
|
||||
<div class="app-item-icon" style="background: linear-gradient(135deg, #6366f1, #4f46e5);">📊</div>
|
||||
<a
|
||||
href="#analytics"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/analytics/analytics.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<div
|
||||
class="app-item-icon"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
#6366f1,
|
||||
#4f46e5
|
||||
);
|
||||
"
|
||||
>
|
||||
📊
|
||||
</div>
|
||||
<span>Analytics</span>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -387,9 +573,22 @@
|
|||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button class="header-btn" id="theme-btn" aria-label="Toggle theme">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
<button
|
||||
class="header-btn"
|
||||
id="theme-btn"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
|
@ -402,9 +601,7 @@
|
|||
|
||||
<!-- Main Content -->
|
||||
<main class="app-main">
|
||||
<div id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
<div id="main-content">{% block content %}{% endblock %}</div>
|
||||
</main>
|
||||
|
||||
<!-- Notifications Container -->
|
||||
|
|
@ -412,76 +609,93 @@
|
|||
|
||||
<script>
|
||||
// Apps menu toggle
|
||||
const appsBtn = document.getElementById('apps-btn');
|
||||
const appsDropdown = document.getElementById('apps-dropdown');
|
||||
const appsBtn = document.getElementById("apps-btn");
|
||||
const appsDropdown = document.getElementById("apps-dropdown");
|
||||
|
||||
appsBtn.addEventListener('click', (e) => {
|
||||
appsBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = appsDropdown.classList.toggle('show');
|
||||
appsBtn.setAttribute('aria-expanded', isOpen);
|
||||
const isOpen = appsDropdown.classList.toggle("show");
|
||||
appsBtn.setAttribute("aria-expanded", isOpen);
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!appsDropdown.contains(e.target) && !appsBtn.contains(e.target)) {
|
||||
appsDropdown.classList.remove('show');
|
||||
appsBtn.setAttribute('aria-expanded', 'false');
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!appsDropdown.contains(e.target) &&
|
||||
!appsBtn.contains(e.target)
|
||||
) {
|
||||
appsDropdown.classList.remove("show");
|
||||
appsBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
|
||||
// Close on escape
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
appsDropdown.classList.remove('show');
|
||||
appsBtn.setAttribute('aria-expanded', 'false');
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
appsDropdown.classList.remove("show");
|
||||
appsBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts for apps
|
||||
document.addEventListener('keydown', (e) => {
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
const shortcuts = {
|
||||
'1': 'chat',
|
||||
'2': 'drive',
|
||||
'3': 'tasks',
|
||||
'4': 'mail',
|
||||
'5': 'calendar',
|
||||
'6': 'meet'
|
||||
1: "chat",
|
||||
2: "drive",
|
||||
3: "tasks",
|
||||
4: "mail",
|
||||
5: "calendar",
|
||||
6: "meet",
|
||||
};
|
||||
if (shortcuts[e.key]) {
|
||||
e.preventDefault();
|
||||
const link = document.querySelector(`a[href="#${shortcuts[e.key]}"]`);
|
||||
const link = document.querySelector(
|
||||
`a[href="#${shortcuts[e.key]}"]`,
|
||||
);
|
||||
if (link) link.click();
|
||||
appsDropdown.classList.remove('show');
|
||||
appsDropdown.classList.remove("show");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update active app in menu
|
||||
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||
if (e.detail.target.id === 'main-content') {
|
||||
const hash = window.location.hash || '#chat';
|
||||
document.querySelectorAll('.app-item').forEach(item => {
|
||||
item.classList.toggle('active', item.getAttribute('href') === hash);
|
||||
document.body.addEventListener("htmx:afterSwap", (e) => {
|
||||
if (e.detail.target.id === "main-content") {
|
||||
const hash = window.location.hash || "#chat";
|
||||
document.querySelectorAll(".app-item").forEach((item) => {
|
||||
item.classList.toggle(
|
||||
"active",
|
||||
item.getAttribute("href") === hash,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Theme toggle
|
||||
const themeBtn = document.getElementById('theme-btn');
|
||||
themeBtn.addEventListener('click', () => {
|
||||
document.body.classList.toggle('light-theme');
|
||||
localStorage.setItem('theme', document.body.classList.contains('light-theme') ? 'light' : 'dark');
|
||||
const themeBtn = document.getElementById("theme-btn");
|
||||
themeBtn.addEventListener("click", () => {
|
||||
document.body.classList.toggle("light-theme");
|
||||
localStorage.setItem(
|
||||
"theme",
|
||||
document.body.classList.contains("light-theme")
|
||||
? "light"
|
||||
: "dark",
|
||||
);
|
||||
});
|
||||
|
||||
// Restore theme
|
||||
if (localStorage.getItem('theme') === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
if (localStorage.getItem("theme") === "light") {
|
||||
document.body.classList.add("light-theme");
|
||||
}
|
||||
|
||||
// Notification helper
|
||||
window.showNotification = function(message, type = 'info', duration = 5000) {
|
||||
const container = document.getElementById('notifications');
|
||||
const notification = document.createElement('div');
|
||||
window.showNotification = function (
|
||||
message,
|
||||
type = "info",
|
||||
duration = 5000,
|
||||
) {
|
||||
const container = document.getElementById("notifications");
|
||||
const notification = document.createElement("div");
|
||||
notification.className = `notification ${type}`;
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
|
|
@ -498,5 +712,5 @@
|
|||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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