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"

475
PROMPT.md
View file

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

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

View file

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

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