Update UI with vendor libraries and PROMPT.md

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-12-04 12:30:07 -03:00
parent 8c17e13e37
commit bd5b2c9481
21 changed files with 2047 additions and 841 deletions

45
Cargo.lock generated
View file

@ -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",
]

View file

@ -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
View file

@ -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

View file

@ -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

File diff suppressed because one or more lines are too long

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
View 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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

69
ui/minimal/js/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -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>

View file

@ -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

File diff suppressed because one or more lines are too long

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
View 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
View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

69
ui/suite/js/vendor/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long