Compare commits
13 commits
497d0dd18c
...
e0504f3703
| Author | SHA1 | Date | |
|---|---|---|---|
| e0504f3703 | |||
| 91a750127c | |||
| 5618ed4367 | |||
| 5e10222a94 | |||
| 375b457f48 | |||
| e135ebf2e6 | |||
| b69ea06ad3 | |||
| bd49ee3892 | |||
| 34d55825bc | |||
| 27e839f22a | |||
| db0f0c1178 | |||
| f4dcae288a | |||
| bb8b35d885 |
26 changed files with 4658 additions and 3361 deletions
|
|
@ -1,23 +1,56 @@
|
|||
name: GBCI
|
||||
name: BotUI CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "botui/**"
|
||||
- "botlib/**"
|
||||
- ".github/workflows/botui.yaml"
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "botui/**"
|
||||
- "botlib/**"
|
||||
- ".github/workflows/botui.yaml"
|
||||
|
||||
env:
|
||||
CARGO_BUILD_JOBS: 8
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_NET_RETRY: 10
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: gbo
|
||||
|
||||
steps:
|
||||
- name: Disable SSL verification (temporary)
|
||||
- name: Disable SSL verification
|
||||
run: git config --global http.sslVerify false
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout BotUI Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: botui
|
||||
|
||||
- name: Clone botlib dependency
|
||||
run: git clone --depth 1 https://github.com/GeneralBots/botlib.git ../botlib
|
||||
- name: Setup Workspace
|
||||
run: |
|
||||
git clone --depth 1 --branch main https://alm.pragmatismo.com.br/GeneralBots/gb.git workspace
|
||||
cd workspace
|
||||
git submodule update --init --depth 1 botlib
|
||||
|
||||
# Remove all members except botui and botlib from workspace
|
||||
sed -i '/"botapp",/d' Cargo.toml
|
||||
sed -i '/"botdevice",/d' Cargo.toml
|
||||
sed -i '/"bottest",/d' Cargo.toml
|
||||
sed -i '/"botserver",/d' Cargo.toml
|
||||
sed -i '/"botbook",/d' Cargo.toml
|
||||
sed -i '/"botmodels",/d' Cargo.toml
|
||||
sed -i '/"botplugin",/d' Cargo.toml
|
||||
sed -i '/"bottemplates",/d' Cargo.toml
|
||||
|
||||
cd ..
|
||||
rm -rf workspace/botui
|
||||
mv botui workspace/botui
|
||||
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
|
|
@ -25,34 +58,54 @@ jobs:
|
|||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-botui-${{ hashFiles('**/Cargo.lock') }}
|
||||
~/.cache/sccache
|
||||
workspace/target
|
||||
key: ${{ runner.os }}-cargo-v2-debug-ui-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-botui-
|
||||
${{ runner.os }}-cargo-v2-debug-ui-
|
||||
${{ runner.os }}-cargo-v2-debug-
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libpq-dev libssl-dev liblzma-dev pkg-config
|
||||
|
||||
- name: Install Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "/root/.cargo/bin" >> $GITHUB_PATH
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Install sccache
|
||||
run: |
|
||||
wget https://github.com/mozilla/sccache/releases/download/v0.8.2/sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz
|
||||
tar xzf sccache-v0.8.2-x86_64-unknown-linux-musl.tar.gz
|
||||
mv sccache-v0.8.2-x86_64-unknown-linux-musl/sccache $HOME/.cargo/bin/sccache
|
||||
chmod +x $HOME/.cargo/bin/sccache
|
||||
echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV
|
||||
$HOME/.cargo/bin/sccache --start-server || true
|
||||
|
||||
- name: Setup environment
|
||||
run: sudo cp /opt/gbo/bin/system/.env . 2>/dev/null || true
|
||||
|
||||
- name: Build BotUI
|
||||
working-directory: workspace
|
||||
run: |
|
||||
sudo cp /opt/gbo/bin/system/botui.env .env
|
||||
cargo build -p botui --features embed-ui -j 8 2>&1 | tee /tmp/build.log
|
||||
ls -lh target/debug/botui
|
||||
sccache --show-stats || true
|
||||
|
||||
- name: Build Linux x86_64
|
||||
run: /root/.cargo/bin/cargo build --locked --release
|
||||
|
||||
- name: Prepare release artifacts
|
||||
- name: Save build log
|
||||
if: always()
|
||||
run: |
|
||||
sudo mkdir -p /opt/gbo/releases/botui/linux-x86_64
|
||||
sudo cp ./target/release/botui /opt/gbo/releases/botui/linux-x86_64/ || true
|
||||
sudo chmod -R 755 /opt/gbo/releases/botui/
|
||||
sudo mkdir -p /opt/gbo/logs
|
||||
sudo cp /tmp/build.log /opt/gbo/logs/botui-$(date +%Y%m%d-%H%M%S).log || true
|
||||
|
||||
- name: Deploy and restart local service
|
||||
- name: Deploy
|
||||
working-directory: workspace
|
||||
run: |
|
||||
lxc exec bot:pragmatismo-system -- systemctl stop botui
|
||||
lxc exec bot:pragmatismo-system -- systemctl stop ui || true
|
||||
|
||||
sudo cp ./target/release/botui /opt/gbo/bin/botui
|
||||
sudo chmod +x /opt/gbo/bin/botui
|
||||
sudo cp target/debug/botui /opt/gbo/bin/system/
|
||||
sudo chmod +x /opt/gbo/bin/system/botui
|
||||
|
||||
lxc exec bot:pragmatismo-system -- systemctl start botui
|
||||
lxc exec bot:pragmatismo-system -- systemctl start ui || true
|
||||
|
|
@ -13,7 +13,7 @@ workspace = true
|
|||
features = ["http-client"]
|
||||
|
||||
[features]
|
||||
default = ["ui-server", "chat", "drive", "tasks"]
|
||||
default = ["ui-server", "chat", "drive", "tasks", "admin"]
|
||||
ui-server = []
|
||||
embed-ui = ["rust-embed"]
|
||||
|
||||
|
|
|
|||
278
PROMPT.md
278
PROMPT.md
|
|
@ -1,278 +0,0 @@
|
|||
# BotUI Development Guide
|
||||
|
||||
**Version:** 6.2.0
|
||||
**Purpose:** Web UI server for General Bots (Axum + HTMX + CSS)
|
||||
|
||||
---
|
||||
|
||||
## ZERO TOLERANCE POLICY
|
||||
|
||||
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
|
||||
|
||||
---
|
||||
|
||||
## ❌ ABSOLUTE PROHIBITIONS
|
||||
|
||||
```
|
||||
❌ NEVER use #![allow()] or #[allow()] in source code
|
||||
❌ NEVER use _ prefix for unused variables - DELETE or USE them
|
||||
❌ NEVER use .unwrap() - use ? or proper error handling
|
||||
❌ NEVER use .expect() - use ? or proper error handling
|
||||
❌ NEVER use panic!() or unreachable!()
|
||||
❌ NEVER use todo!() or unimplemented!()
|
||||
❌ NEVER leave unused imports or dead code
|
||||
❌ NEVER add comments - code must be self-documenting
|
||||
❌ NEVER use CDN links - all assets must be local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ ARCHITECTURE
|
||||
|
||||
### Dual Modes
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| Web | `cargo run` | Axum server on port 3000 |
|
||||
| Desktop | `cargo tauri dev` | Tauri native window |
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point - mode detection
|
||||
├── lib.rs # Feature-gated module exports
|
||||
├── http_client.rs # HTTP wrapper for botserver
|
||||
├── ui_server/
|
||||
│ └── mod.rs # Axum router + UI serving
|
||||
├── desktop/
|
||||
│ ├── mod.rs # Desktop module organization
|
||||
│ ├── drive.rs # File operations via Tauri
|
||||
│ └── tray.rs # System tray
|
||||
└── shared/
|
||||
└── state.rs # Shared application state
|
||||
|
||||
ui/
|
||||
├── suite/ # Main UI (HTML/CSS/JS)
|
||||
│ ├── js/vendor/ # Local JS libraries
|
||||
│ └── css/ # Stylesheets
|
||||
└── minimal/ # Minimal chat UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 HTMX-FIRST FRONTEND
|
||||
|
||||
### Core Principle
|
||||
- **Use HTMX** to minimize JavaScript
|
||||
- **Server returns HTML fragments**, not JSON
|
||||
- **Delegate ALL logic** to Rust server
|
||||
|
||||
### HTMX Usage
|
||||
|
||||
| Use Case | Solution |
|
||||
|----------|----------|
|
||||
| Data fetching | `hx-get`, `hx-post` |
|
||||
| Form submission | `hx-post`, `hx-put` |
|
||||
| Real-time updates | `hx-ext="ws"` |
|
||||
| Content swapping | `hx-target`, `hx-swap` |
|
||||
| Polling | `hx-trigger="every 5s"` |
|
||||
| Loading states | `hx-indicator` |
|
||||
|
||||
### When JS is Required
|
||||
|
||||
| Use Case | Why JS Required |
|
||||
|----------|-----------------|
|
||||
| Modal show/hide | DOM manipulation |
|
||||
| Toast notifications | Dynamic element creation |
|
||||
| Clipboard operations | `navigator.clipboard` API |
|
||||
| Keyboard shortcuts | `keydown` event handling |
|
||||
| Complex animations | GSAP or custom |
|
||||
|
||||
---
|
||||
|
||||
## 📦 LOCAL ASSETS ONLY - NO CDN
|
||||
|
||||
```
|
||||
ui/suite/js/vendor/
|
||||
├── htmx.min.js
|
||||
├── htmx-ws.js
|
||||
├── marked.min.js
|
||||
├── gsap.min.js
|
||||
└── livekit-client.umd.min.js
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- ✅ CORRECT -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
|
||||
<!-- ❌ WRONG -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 OFFICIAL ICONS - MANDATORY
|
||||
|
||||
**NEVER generate icons with LLM. Use official SVG icons:**
|
||||
|
||||
```
|
||||
ui/suite/assets/icons/
|
||||
├── gb-logo.svg # Main GB logo
|
||||
├── gb-bot.svg # Bot/assistant
|
||||
├── gb-analytics.svg # Analytics
|
||||
├── gb-calendar.svg # Calendar
|
||||
├── gb-chat.svg # Chat
|
||||
├── gb-drive.svg # File storage
|
||||
├── gb-mail.svg # Email
|
||||
├── gb-meet.svg # Video meetings
|
||||
├── gb-tasks.svg # Task management
|
||||
└── ...
|
||||
```
|
||||
|
||||
All icons use `stroke="currentColor"` for CSS theming.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 SECURITY ARCHITECTURE
|
||||
|
||||
### Centralized Auth Engine
|
||||
|
||||
All authentication is handled by `security-bootstrap.js` which MUST be loaded immediately after HTMX:
|
||||
|
||||
```html
|
||||
<head>
|
||||
<!-- 1. HTMX first -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
<script src="js/vendor/htmx-ws.js"></script>
|
||||
|
||||
<!-- 2. Security bootstrap immediately after -->
|
||||
<script src="js/security-bootstrap.js"></script>
|
||||
|
||||
<!-- 3. Other scripts -->
|
||||
<script src="js/api-client.js"></script>
|
||||
</head>
|
||||
```
|
||||
|
||||
### DO NOT Duplicate Auth Logic
|
||||
|
||||
```javascript
|
||||
// ❌ WRONG - Don't add auth headers manually
|
||||
fetch("/api/data", {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
|
||||
// ✅ CORRECT - Let security-bootstrap.js handle it
|
||||
fetch("/api/data");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 DESIGN SYSTEM
|
||||
|
||||
### Layout Standards
|
||||
|
||||
```css
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Variables Required
|
||||
|
||||
```css
|
||||
[data-theme="your-theme"] {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #161616;
|
||||
--surface-hover: #1e1e1e;
|
||||
--border: #2a2a2a;
|
||||
--text: #ffffff;
|
||||
--text-secondary: #888888;
|
||||
--primary: #c5f82a;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ CODE PATTERNS
|
||||
|
||||
### Error Handling
|
||||
|
||||
```rust
|
||||
// ❌ WRONG
|
||||
let value = something.unwrap();
|
||||
|
||||
// ✅ CORRECT
|
||||
let value = something?;
|
||||
let value = something.ok_or_else(|| Error::NotFound)?;
|
||||
```
|
||||
|
||||
### Self Usage
|
||||
|
||||
```rust
|
||||
impl MyStruct {
|
||||
fn new() -> Self { Self { } } // ✅ Not MyStruct
|
||||
}
|
||||
```
|
||||
|
||||
### Format Strings
|
||||
|
||||
```rust
|
||||
format!("Hello {name}") // ✅ Not format!("{}", name)
|
||||
```
|
||||
|
||||
### Derive Eq with PartialEq
|
||||
|
||||
```rust
|
||||
#[derive(PartialEq, Eq)] // ✅ Always both
|
||||
struct MyStruct { }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 KEY DEPENDENCIES
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| axum | 0.7.5 | Web framework |
|
||||
| reqwest | 0.12 | HTTP client |
|
||||
| tokio | 1.41 | Async runtime |
|
||||
| askama | 0.12 | HTML Templates |
|
||||
|
||||
---
|
||||
|
||||
## 🔑 REMEMBER
|
||||
|
||||
- **ZERO WARNINGS** - Every clippy warning must be fixed
|
||||
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
|
||||
- **NO DEAD CODE** - Delete unused code
|
||||
- **NO UNWRAP/EXPECT** - Use ? operator
|
||||
- **HTMX first** - Minimize JS, delegate to server
|
||||
- **Local assets** - No CDN, all vendor files local
|
||||
- **No business logic** - All logic in botserver
|
||||
- **HTML responses** - Server returns fragments, not JSON
|
||||
- **Version 6.2.0** - do not change without approval
|
||||
317
README.md
317
README.md
|
|
@ -1,39 +1,314 @@
|
|||
# BotUI - General Bots Web Interface
|
||||
|
||||
# General Bots Desktop
|
||||
**Version:** 6.2.0
|
||||
**Purpose:** Web UI server for General Bots (Axum + HTMX + CSS)
|
||||
|
||||
An AI-powered desktop automation tool that records and plays back user interactions useful for legacy systems and common desktop tasks. The BotDesktop automation tool fills a critical gap in the enterprise automation landscape by addressing legacy systems and desktop applications that lack modern APIs or integration capabilities. While botserver excels at creating conversational bots for modern channels like web, mobile and messaging platforms, many organizations still rely heavily on traditional desktop applications, mainframe systems, and custom internal tools that can only be accessed through their user interface. BotDesktop's ability to record and replay user interactions provides a practical bridge between these legacy systems and modern automation needs.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||

|
||||
BotUI is a modern web interface for General Bots, built with Rust, Axum, and HTMX. It provides a clean, responsive interface for interacting with the General Bots platform, featuring real-time updates via WebSocket connections and a minimalist JavaScript approach powered by HTMX.
|
||||
|
||||
The interface supports multiple features including chat, file management, tasks, calendar, analytics, and more - all served through a fast, efficient Rust backend with a focus on server-rendered HTML and minimal client-side JavaScript.
|
||||
|
||||
The tool's AI-powered approach to desktop automation represents a significant advancement over traditional robotic process automation (RPA) tools. By leveraging machine learning to understand screen elements and user interactions, BotDesktop can adapt to minor UI changes and variations that would break conventional scripted automation. This resilience is particularly valuable in enterprise environments where applications receive regular updates or where slight variations exist between different versions or installations of the same software. The AI component also simplifies the creation of automation scripts - instead of requiring complex programming, users can simply demonstrate the desired actions which BotDesktop observes and learns to replicate.
|
||||
For comprehensive documentation, see **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** or the **[BotBook](./botbook)** for detailed guides and API references.
|
||||
|
||||
---
|
||||
|
||||
From an integration perspective, BotDesktop complements botserver by enabling end-to-end automation scenarios that span both modern and legacy systems. For example, a bot created in botserver could collect information from users through a modern chat interface, then use BotDesktop to input that data into a legacy desktop application that lacks API access. This hybrid approach allows organizations to modernize their user interactions while still leveraging their existing IT investments. Additionally, BotDesktop can automate routine desktop tasks like file management, data entry, and application monitoring that fall outside the scope of conversational bot interactions.
|
||||
## Quick Start
|
||||
|
||||
|
||||
The combined toolset of botserver and BotDesktop provides organizations with comprehensive automation capabilities across their entire technology stack. While botserver handles the modern, API-driven interactions with users across multiple channels, BotDesktop extends automation capabilities to the desktop environment where many critical business processes still reside. This dual approach allows organizations to progressively modernize their systems while maintaining operational efficiency through automation of both new and legacy components. The result is a more flexible and complete automation solution that can adapt to various technical environments and business needs.
|
||||
## Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# Development mode - starts Axum server on port 3000
|
||||
cargo run
|
||||
|
||||
# Desktop mode (Tauri) - starts native window
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
2. Create a .env file with your Azure OpenAI credentials
|
||||
### Environment Variables
|
||||
|
||||
3. Development:
|
||||
```bash
|
||||
npm run dev
|
||||
- `BOTUI_PORT` - Server port (default: 3000)
|
||||
|
||||
---
|
||||
|
||||
## ZERO TOLERANCE POLICY
|
||||
|
||||
**EVERY SINGLE WARNING MUST BE FIXED. NO EXCEPTIONS.**
|
||||
|
||||
---
|
||||
|
||||
## ❌ ABSOLUTE PROHIBITIONS
|
||||
|
||||
```
|
||||
❌ NEVER use #![allow()] or #[allow()] in source code
|
||||
❌ NEVER use _ prefix for unused variables - DELETE or USE them
|
||||
❌ NEVER use .unwrap() - use ? or proper error handling
|
||||
❌ NEVER use .expect() - use ? or proper error handling
|
||||
❌ NEVER use panic!() or unreachable!()
|
||||
❌ NEVER use todo!() or unimplemented!()
|
||||
❌ NEVER leave unused imports or dead code
|
||||
❌ NEVER add comments - code must be self-documenting
|
||||
❌ NEVER use CDN links - all assets must be local
|
||||
```
|
||||
|
||||
4. Build:
|
||||
```bash
|
||||
npm run build
|
||||
---
|
||||
|
||||
## 🏗️ ARCHITECTURE
|
||||
|
||||
### Dual Modes
|
||||
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| Web | `cargo run` | Axum server on port 3000 |
|
||||
| Desktop | `cargo tauri dev` | Tauri native window |
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point - mode detection
|
||||
├── lib.rs # Feature-gated module exports
|
||||
├── http_client.rs # HTTP wrapper for botserver
|
||||
├── ui_server/
|
||||
│ └── mod.rs # Axum router + UI serving
|
||||
├── desktop/
|
||||
│ ├── mod.rs # Desktop module organization
|
||||
│ ├── drive.rs # File operations via Tauri
|
||||
│ └── tray.rs # System tray
|
||||
└── shared/
|
||||
└── state.rs # Shared application state
|
||||
|
||||
ui/
|
||||
├── suite/ # Main UI (HTML/CSS/JS)
|
||||
│ ├── js/vendor/ # Local JS libraries
|
||||
│ └── css/ # Stylesheets
|
||||
└── minimal/ # Minimal chat UI
|
||||
```
|
||||
|
||||
## Testing
|
||||
```bash
|
||||
npm test
|
||||
---
|
||||
|
||||
## 🎨 HTMX-FIRST FRONTEND
|
||||
|
||||
### Core Principle
|
||||
- **Use HTMX** to minimize JavaScript
|
||||
- **Server returns HTML fragments**, not JSON
|
||||
- **Delegate ALL logic** to Rust server
|
||||
|
||||
### HTMX Usage
|
||||
|
||||
| Use Case | Solution |
|
||||
|----------|----------|
|
||||
| Data fetching | `hx-get`, `hx-post` |
|
||||
| Form submission | `hx-post`, `hx-put` |
|
||||
| Real-time updates | `hx-ext="ws"` |
|
||||
| Content swapping | `hx-target`, `hx-swap` |
|
||||
| Polling | `hx-trigger="every 5s"` |
|
||||
| Loading states | `hx-indicator` |
|
||||
|
||||
### When JS is Required
|
||||
|
||||
| Use Case | Why JS Required |
|
||||
|----------|-----------------|
|
||||
| Modal show/hide | DOM manipulation |
|
||||
| Toast notifications | Dynamic element creation |
|
||||
| Clipboard operations | `navigator.clipboard` API |
|
||||
| Keyboard shortcuts | `keydown` event handling |
|
||||
| Complex animations | GSAP or custom |
|
||||
|
||||
---
|
||||
|
||||
## 📦 LOCAL ASSETS ONLY - NO CDN
|
||||
|
||||
```
|
||||
ui/suite/js/vendor/
|
||||
├── htmx.min.js
|
||||
├── htmx-ws.js
|
||||
├── marked.min.js
|
||||
├── gsap.min.js
|
||||
└── livekit-client.umd.min.js
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- ✅ CORRECT -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
|
||||
<!-- ❌ WRONG -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 OFFICIAL ICONS - MANDATORY
|
||||
|
||||
**NEVER generate icons with LLM. Use official SVG icons:**
|
||||
|
||||
```
|
||||
ui/suite/assets/icons/
|
||||
├── gb-logo.svg # Main GB logo
|
||||
├── gb-bot.svg # Bot/assistant
|
||||
├── gb-analytics.svg # Analytics
|
||||
├── gb-calendar.svg # Calendar
|
||||
├── gb-chat.svg # Chat
|
||||
├── gb-drive.svg # File storage
|
||||
├── gb-mail.svg # Email
|
||||
├── gb-meet.svg # Video meetings
|
||||
├── gb-tasks.svg # Task management
|
||||
└── ...
|
||||
```
|
||||
|
||||
All icons use `stroke="currentColor"` for CSS theming.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 SECURITY ARCHITECTURE
|
||||
|
||||
### Centralized Auth Engine
|
||||
|
||||
All authentication is handled by `security-bootstrap.js` which MUST be loaded immediately after HTMX:
|
||||
|
||||
```html
|
||||
<head>
|
||||
<!-- 1. HTMX first -->
|
||||
<script src="js/vendor/htmx.min.js"></script>
|
||||
<script src="js/vendor/htmx-ws.js"></script>
|
||||
|
||||
<!-- 2. Security bootstrap immediately after -->
|
||||
<script src="js/security-bootstrap.js"></script>
|
||||
|
||||
<!-- 3. Other scripts -->
|
||||
<script src="js/api-client.js"></script>
|
||||
</head>
|
||||
```
|
||||
|
||||
### DO NOT Duplicate Auth Logic
|
||||
|
||||
```javascript
|
||||
// ❌ WRONG - Don't add auth headers manually
|
||||
fetch("/api/data", {
|
||||
headers: { "Authorization": "Bearer " + token }
|
||||
});
|
||||
|
||||
// ✅ CORRECT - Let security-bootstrap.js handle it
|
||||
fetch("/api/data");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 DESIGN SYSTEM
|
||||
|
||||
### Layout Standards
|
||||
|
||||
```css
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
### Theme Variables Required
|
||||
|
||||
```css
|
||||
[data-theme="your-theme"] {
|
||||
--bg: #0a0a0a;
|
||||
--surface: #161616;
|
||||
--surface-hover: #1e1e1e;
|
||||
--border: #2a2a2a;
|
||||
--text: #ffffff;
|
||||
--text-secondary: #888888;
|
||||
--primary: #c5f82a;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ CODE PATTERNS
|
||||
|
||||
### Error Handling
|
||||
|
||||
```rust
|
||||
// ❌ WRONG
|
||||
let value = something.unwrap();
|
||||
|
||||
// ✅ CORRECT
|
||||
let value = something?;
|
||||
let value = something.ok_or_else(|| Error::NotFound)?;
|
||||
```
|
||||
|
||||
### Self Usage
|
||||
|
||||
```rust
|
||||
impl MyStruct {
|
||||
fn new() -> Self { Self { } } // ✅ Not MyStruct
|
||||
}
|
||||
```
|
||||
|
||||
### Format Strings
|
||||
|
||||
```rust
|
||||
format!("Hello {name}") // ✅ Not format!("{}", name)
|
||||
```
|
||||
|
||||
### Derive Eq with PartialEq
|
||||
|
||||
```rust
|
||||
#[derive(PartialEq, Eq)] // ✅ Always both
|
||||
struct MyStruct { }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 KEY DEPENDENCIES
|
||||
|
||||
| Library | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| axum | 0.7.5 | Web framework |
|
||||
| reqwest | 0.12 | HTTP client |
|
||||
| tokio | 1.41 | Async runtime |
|
||||
| askama | 0.12 | HTML Templates |
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
For complete documentation, guides, and API references:
|
||||
|
||||
- **[docs.pragmatismo.com.br](https://docs.pragmatismo.com.br)** - Full online documentation
|
||||
- **[BotBook](./botbook)** - Local comprehensive guide
|
||||
- **[General Bots Repository](https://github.com/GeneralBots/BotServer)** - Main project repository
|
||||
|
||||
---
|
||||
|
||||
## 🔑 REMEMBER
|
||||
|
||||
- **ZERO WARNINGS** - Every clippy warning must be fixed
|
||||
- **NO ALLOW IN CODE** - Never use #[allow()] in source files
|
||||
- **NO DEAD CODE** - Delete unused code
|
||||
- **NO UNWRAP/EXPECT** - Use ? operator
|
||||
- **HTMX first** - Minimize JS, delegate to server
|
||||
- **Local assets** - No CDN, all vendor files local
|
||||
- **No business logic** - All logic in botserver
|
||||
- **HTML responses** - Server returns fragments, not JSON
|
||||
- **Version 6.2.0** - do not change without approval
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
use log::info;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ use axum::{
|
|||
http::{Request, StatusCode},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::{any, get},
|
||||
Router,
|
||||
Json, Router,
|
||||
};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use log::{debug, error, info};
|
||||
use log::{debug, error, info, warn};
|
||||
#[cfg(feature = "embed-ui")]
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::Deserialize;
|
||||
|
|
@ -20,7 +20,6 @@ use tokio_tungstenite::{
|
|||
connect_async_tls_with_config, tungstenite,
|
||||
tungstenite::protocol::Message as TungsteniteMessage,
|
||||
};
|
||||
#[cfg(not(feature = "embed-ui"))]
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[cfg(feature = "embed-ui")]
|
||||
|
|
@ -130,8 +129,98 @@ const ROOT_FILES: &[&str] = &[
|
|||
"single.gbui",
|
||||
];
|
||||
|
||||
pub async fn index() -> impl IntoResponse {
|
||||
serve_suite().await
|
||||
pub async fn index(OriginalUri(uri): OriginalUri) -> Response {
|
||||
let path = uri.path();
|
||||
|
||||
// Check if path contains static asset directories - serve them directly
|
||||
let path_lower = path.to_lowercase();
|
||||
if path_lower.contains("/js/")
|
||||
|| path_lower.contains("/css/")
|
||||
|| path_lower.contains("/vendor/")
|
||||
|| path_lower.contains("/assets/")
|
||||
|| path_lower.contains("/public/")
|
||||
|| path_lower.contains("/partials/")
|
||||
|| path_lower.ends_with(".js")
|
||||
|| path_lower.ends_with(".css")
|
||||
|| path_lower.ends_with(".png")
|
||||
|| path_lower.ends_with(".jpg")
|
||||
|| path_lower.ends_with(".jpeg")
|
||||
|| path_lower.ends_with(".gif")
|
||||
|| path_lower.ends_with(".svg")
|
||||
|| path_lower.ends_with(".ico")
|
||||
|| path_lower.ends_with(".woff")
|
||||
|| path_lower.ends_with(".woff2")
|
||||
|| path_lower.ends_with(".ttf")
|
||||
|| path_lower.ends_with(".eot")
|
||||
|| path_lower.ends_with(".mp4")
|
||||
|| path_lower.ends_with(".webm")
|
||||
|| path_lower.ends_with(".mp3")
|
||||
|| path_lower.ends_with(".wav")
|
||||
{
|
||||
// Remove bot name prefix if present (e.g., /edu/suite/js/file.js -> suite/js/file.js)
|
||||
let path_parts: Vec<&str> = path.split('/').collect();
|
||||
let fs_path = if path_parts.len() > 1 {
|
||||
let mut start_idx = 1;
|
||||
let known_dirs = ["suite", "js", "css", "vendor", "assets", "public", "partials", "settings", "auth", "about", "drive", "chat", "tasks", "admin", "mail", "calendar", "meet", "docs", "sheet", "slides", "paper", "research", "sources", "learn", "analytics", "dashboards", "monitoring", "people", "crm", "tickets", "billing", "products", "video", "player", "canvas", "social", "project", "goals", "workspace", "designer"];
|
||||
|
||||
if path_parts.len() > start_idx && !known_dirs.contains(&path_parts[start_idx]) {
|
||||
start_idx += 1;
|
||||
}
|
||||
|
||||
path_parts[start_idx..].join("/")
|
||||
} else {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
let full_path = get_ui_root().join(&fs_path);
|
||||
|
||||
debug!("index: Serving static file: {} -> {:?} (fs_path: {})", path, full_path, fs_path);
|
||||
|
||||
#[cfg(feature = "embed-ui")]
|
||||
{
|
||||
let asset_path = fs_path.trim_start_matches('/');
|
||||
if let Some(content) = Assets::get(asset_path) {
|
||||
let mime = mime_guess::from_path(asset_path).first_or_octet_stream();
|
||||
return ([(axum::http::header::CONTENT_TYPE, mime.as_ref())], content.data).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "embed-ui"))]
|
||||
{
|
||||
if let Ok(bytes) = tokio::fs::read(&full_path).await {
|
||||
let mime = mime_guess::from_path(&full_path).first_or_octet_stream();
|
||||
return (StatusCode::OK, [("content-type", mime.as_ref())], bytes).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
warn!("index: Static file not found: {} -> {:?}", path, full_path);
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let path_parts: Vec<&str> = path.split('/').collect();
|
||||
let bot_name = path_parts
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|part| {
|
||||
!part.is_empty()
|
||||
&& **part != "chat"
|
||||
&& **part != "app"
|
||||
&& **part != "ws"
|
||||
&& **part != "ui"
|
||||
&& **part != "api"
|
||||
&& **part != "auth"
|
||||
&& **part != "suite"
|
||||
&& !part.ends_with(".js")
|
||||
&& !part.ends_with(".css")
|
||||
})
|
||||
.map(|s| s.to_string());
|
||||
|
||||
info!(
|
||||
"index: Extracted bot_name: {:?} from path: {}",
|
||||
bot_name,
|
||||
path
|
||||
);
|
||||
serve_suite(bot_name).await.into_response()
|
||||
}
|
||||
|
||||
pub fn get_ui_root() -> PathBuf {
|
||||
|
|
@ -196,7 +285,7 @@ pub async fn serve_minimal() -> impl IntoResponse {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn serve_suite() -> impl IntoResponse {
|
||||
pub async fn serve_suite(bot_name: Option<String>) -> impl IntoResponse {
|
||||
let raw_html_res = {
|
||||
#[cfg(feature = "embed-ui")]
|
||||
{
|
||||
|
|
@ -235,6 +324,32 @@ pub async fn serve_suite() -> impl IntoResponse {
|
|||
#[allow(unused_mut)] // Mutable required for feature-gated blocks
|
||||
let mut html = raw_html;
|
||||
|
||||
// Inject base tag and bot_name into the page
|
||||
if let Some(head_end) = html.find("</head>") {
|
||||
// Set base href to include bot context if present (e.g., /edu/)
|
||||
let base_href = if let Some(ref name) = bot_name {
|
||||
format!("/{}/", name)
|
||||
} else {
|
||||
"/".to_string()
|
||||
};
|
||||
let base_tag = format!(r#"<base href="{}">"#, base_href);
|
||||
html.insert_str(head_end, &base_tag);
|
||||
|
||||
if let Some(name) = bot_name {
|
||||
info!("serve_suite: Injecting bot_name '{}' into page with base href='{}'", name, base_href);
|
||||
let bot_script = format!(
|
||||
r#"<script>window.__INITIAL_BOT_NAME__ = "{}";</script>"#,
|
||||
&name
|
||||
);
|
||||
html.insert_str(head_end + base_tag.len(), &bot_script);
|
||||
info!("serve_suite: Successfully injected base tag and bot_name script");
|
||||
} else {
|
||||
info!("serve_suite: Successfully injected base tag (no bot_name)");
|
||||
}
|
||||
} else {
|
||||
error!("serve_suite: Failed to find </head> tag to inject content");
|
||||
}
|
||||
|
||||
// Core Apps
|
||||
#[cfg(not(feature = "chat"))]
|
||||
{
|
||||
|
|
@ -452,14 +567,26 @@ async fn health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_
|
|||
}
|
||||
}
|
||||
|
||||
async fn api_health() -> (StatusCode, axum::Json<serde_json::Value>) {
|
||||
async fn api_health(State(state): State<AppState>) -> (StatusCode, axum::Json<serde_json::Value>) {
|
||||
if state.health_check().await {
|
||||
(
|
||||
StatusCode::OK,
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"botserver": "healthy",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
})),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "error",
|
||||
"botserver": "unhealthy",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<String> {
|
||||
|
|
@ -588,6 +715,7 @@ async fn build_proxy_response(resp: reqwest::Response) -> Response<Body> {
|
|||
fn create_api_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/health", get(api_health))
|
||||
.route("/client-error", axum::routing::post(handle_client_error))
|
||||
.fallback(any(proxy_api))
|
||||
}
|
||||
|
||||
|
|
@ -595,6 +723,36 @@ fn create_api_router() -> Router<AppState> {
|
|||
struct WsQuery {
|
||||
session_id: String,
|
||||
user_id: String,
|
||||
bot_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ClientError {
|
||||
message: String,
|
||||
stack: Option<String>,
|
||||
source: String,
|
||||
url: String,
|
||||
user_agent: String,
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
async fn handle_client_error(Json(error): Json<ClientError>) -> impl IntoResponse {
|
||||
warn!(
|
||||
"CLIENT:{}: {} at {} ({}) - {}",
|
||||
error.source.to_uppercase(),
|
||||
error.message,
|
||||
error.url,
|
||||
error.timestamp,
|
||||
error.user_agent
|
||||
);
|
||||
|
||||
if let Some(stack) = &error.stack {
|
||||
if !stack.is_empty() {
|
||||
warn!("CLIENT:STACK: {}", stack);
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
|
|
@ -605,9 +763,31 @@ struct OptionalWsQuery {
|
|||
async fn ws_proxy(
|
||||
ws: WebSocketUpgrade,
|
||||
State(state): State<AppState>,
|
||||
OriginalUri(uri): OriginalUri,
|
||||
Query(params): Query<WsQuery>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params))
|
||||
// Extract bot_name from URL path (e.g., /edu, /chat/edu)
|
||||
let path_parts: Vec<&str> = uri.path().split('/').collect();
|
||||
let bot_name = params
|
||||
.bot_name
|
||||
.filter(|name| name != "ws" && !name.is_empty())
|
||||
.or_else(|| {
|
||||
// Try to extract from path like /edu or /app/edu
|
||||
path_parts
|
||||
.iter()
|
||||
.find(|part| {
|
||||
!part.is_empty() && **part != "chat" && **part != "app" && **part != "ws"
|
||||
})
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
let params_with_bot = WsQuery {
|
||||
bot_name: Some(bot_name),
|
||||
..params
|
||||
};
|
||||
|
||||
ws.on_upgrade(move |socket| handle_ws_proxy(socket, state, params_with_bot))
|
||||
}
|
||||
|
||||
async fn ws_task_progress_proxy(
|
||||
|
|
@ -761,14 +941,15 @@ async fn handle_task_progress_ws_proxy(
|
|||
#[allow(clippy::too_many_lines)]
|
||||
async fn handle_ws_proxy(client_socket: WebSocket, state: AppState, params: WsQuery) {
|
||||
let backend_url = format!(
|
||||
"{}/ws?session_id={}&user_id={}",
|
||||
"{}/ws?session_id={}&user_id={}&bot_name={}",
|
||||
state
|
||||
.client
|
||||
.base_url()
|
||||
.replace("https://", "wss://")
|
||||
.replace("http://", "ws://"),
|
||||
params.session_id,
|
||||
params.user_id
|
||||
params.user_id,
|
||||
params.bot_name.unwrap_or_else(|| "default".to_string())
|
||||
);
|
||||
|
||||
info!("Proxying WebSocket to: {backend_url}");
|
||||
|
|
@ -975,11 +1156,14 @@ fn add_static_routes(router: Router<AppState>, _suite_path: &Path) -> Router<App
|
|||
#[cfg(not(feature = "embed-ui"))]
|
||||
{
|
||||
let mut r = router;
|
||||
// Serve suite directories at BOTH root level and /suite/{dir} path
|
||||
// This allows HTML files to reference js/vendor/file.js directly
|
||||
for dir in SUITE_DIRS {
|
||||
let path = _suite_path.join(dir);
|
||||
r = r
|
||||
.nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone()))
|
||||
.nest_service(&format!("/{dir}"), ServeDir::new(path));
|
||||
info!("Adding route for /{} -> {:?}", dir, path);
|
||||
r = r.nest_service(&format!("/{dir}"), ServeDir::new(path.clone()));
|
||||
info!("Adding route for /suite/{} -> {:?}", dir, path);
|
||||
r = r.nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone()));
|
||||
}
|
||||
|
||||
for file in ROOT_FILES {
|
||||
|
|
@ -1002,12 +1186,14 @@ pub fn configure_router() -> Router {
|
|||
.nest("/ui", create_ui_router())
|
||||
.nest("/ws", create_ws_router())
|
||||
.nest("/apps", create_apps_router())
|
||||
.route("/", get(index))
|
||||
.route("/minimal", get(serve_minimal))
|
||||
.route("/suite", get(serve_suite))
|
||||
.route("/favicon.ico", get(serve_favicon));
|
||||
|
||||
router = add_static_routes(router, &suite_path);
|
||||
|
||||
router.fallback(get(index)).with_state(state)
|
||||
router
|
||||
.route("/", get(index))
|
||||
.route("/minimal", get(serve_minimal))
|
||||
.route("/suite", get(serve_suite))
|
||||
.fallback(get(index))
|
||||
.with_state(state)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Login - General Bots</title>
|
||||
<script src="/js/vendor/htmx.min.js"></script>
|
||||
<script src="/js/vendor/htmx-json-enc.js"></script>
|
||||
<script src="/suite/js/vendor/htmx.min.js"></script>
|
||||
<script src="/suite/js/vendor/htmx-json-enc.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
|
|
@ -1264,12 +1264,18 @@
|
|||
|
||||
// Successful login - redirect
|
||||
if (response.redirect || response.success) {
|
||||
window.location.href = response.redirect || "/";
|
||||
// Check for redirect parameter in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const redirectUrl = urlParams.get('redirect') || response.redirect;
|
||||
window.location.href = redirectUrl ? redirectUrl : window.location.origin + "/#chat";
|
||||
}
|
||||
} catch (e) {
|
||||
// If response is not JSON, check for redirect header
|
||||
if (event.detail.xhr.status === 200) {
|
||||
window.location.href = "/";
|
||||
// Check for redirect parameter in URL
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const redirectUrl = urlParams.get('redirect');
|
||||
window.location.href = redirectUrl ? redirectUrl : window.location.origin + "/#chat";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@
|
|||
<script src="/js/vendor/htmx-ws.js"></script>
|
||||
<script src="/js/vendor/htmx-json-enc.js"></script>
|
||||
|
||||
<!-- ERROR REPORTER - Captures JS errors and sends to server log -->
|
||||
<script src="/js/error-reporter.js"></script>
|
||||
|
||||
<!-- i18n -->
|
||||
<script src="/js/i18n.js"></script>
|
||||
|
||||
|
|
@ -116,7 +119,7 @@
|
|||
href="#tasks"
|
||||
class="app-item"
|
||||
role="menuitem"
|
||||
hx-get="/tasks/tasks.html"
|
||||
hx-get="/suite/tasks/autotask.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="true"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<link rel="stylesheet" href="calendar/calendar.css" />
|
||||
<link rel="stylesheet" href="/suite/calendar/calendar.css" />
|
||||
|
||||
<!-- Calendar - Event Management -->
|
||||
<div class="calendar-container" id="calendar-app">
|
||||
|
|
|
|||
|
|
@ -574,6 +574,13 @@ background: var(--accent-hover, #2563eb);
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.entity-card-btm {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.entity-card-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
|
|
@ -643,12 +650,26 @@ form.input-container {
|
|||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
caret-color: var(--accent, #3b82f6);
|
||||
}
|
||||
|
||||
#messageInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 50% {
|
||||
caret-color: var(--accent, #3b82f6);
|
||||
}
|
||||
51%, 100% {
|
||||
caret-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
#messageInput:focus {
|
||||
animation: cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
#messageInput::placeholder {
|
||||
color: #888888;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
<link rel="stylesheet" href="chat/chat.css" />
|
||||
<link rel="stylesheet" href="/suite/chat/chat.css" />
|
||||
|
||||
<div class="chat-layout" id="chat-app">
|
||||
<main id="messages"></main>
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
id="voiceBtn"
|
||||
title="Voice"
|
||||
data-i18n-title="chat-voice"
|
||||
style="display: none"
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
|
|
@ -81,7 +82,7 @@
|
|||
|
||||
var WS_BASE_URL =
|
||||
window.location.protocol === "https:" ? "wss://" : "ws://";
|
||||
var WS_URL = WS_BASE_URL + window.location.host;
|
||||
var WS_URL = WS_BASE_URL + window.location.host + "/ws/chat";
|
||||
|
||||
var MessageType = {
|
||||
EXTERNAL: 0,
|
||||
|
|
@ -148,6 +149,7 @@
|
|||
var currentSessionId = null;
|
||||
var currentUserId = null;
|
||||
var currentBotId = "default";
|
||||
var currentBotName = "default";
|
||||
var isStreaming = false;
|
||||
var streamingMessageId = null;
|
||||
var currentStreamingContent = "";
|
||||
|
|
@ -733,10 +735,12 @@
|
|||
|
||||
var url =
|
||||
WS_URL +
|
||||
"/ws?session_id=" +
|
||||
"?session_id=" +
|
||||
currentSessionId +
|
||||
"&user_id=" +
|
||||
currentUserId;
|
||||
currentUserId +
|
||||
"&bot_name=" +
|
||||
currentBotName;
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = function () {
|
||||
|
|
@ -747,9 +751,43 @@
|
|||
ws.onmessage = function (event) {
|
||||
try {
|
||||
var data = JSON.parse(event.data);
|
||||
console.log("Chat WebSocket received:", data);
|
||||
|
||||
// Ignore connection confirmation
|
||||
if (data.type === "connected") return;
|
||||
|
||||
// Ignore system events (theme changes, etc)
|
||||
if (data.event) {
|
||||
console.log(
|
||||
"System event received, ignoring:",
|
||||
data.event,
|
||||
data,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if content contains theme change events (JSON strings)
|
||||
if (data.content && typeof data.content === "string") {
|
||||
try {
|
||||
var contentObj = JSON.parse(data.content);
|
||||
if (contentObj.event === "change_theme") {
|
||||
console.log(
|
||||
"Theme change event in content, ignoring:",
|
||||
contentObj,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// Content is not JSON, continue processing
|
||||
}
|
||||
}
|
||||
|
||||
// Only process bot responses
|
||||
if (data.message_type === MessageType.BOT_RESPONSE) {
|
||||
console.log("Processing bot response:", data);
|
||||
processMessage(data);
|
||||
} else {
|
||||
console.log("Ignoring non-bot message:", data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("WS message error:", e);
|
||||
|
|
@ -770,7 +808,12 @@
|
|||
}
|
||||
|
||||
function initChat() {
|
||||
var botName = "default";
|
||||
// Just proceed with chat initialization - no auth check
|
||||
proceedWithChatInit();
|
||||
}
|
||||
|
||||
function proceedWithChatInit() {
|
||||
var botName = window.__INITIAL_BOT_NAME__ || "default";
|
||||
fetch("/api/auth?bot_name=" + encodeURIComponent(botName))
|
||||
.then(function (response) {
|
||||
return response.json();
|
||||
|
|
@ -779,17 +822,19 @@
|
|||
currentUserId = auth.user_id;
|
||||
currentSessionId = auth.session_id;
|
||||
currentBotId = auth.bot_id || "default";
|
||||
currentBotName = botName;
|
||||
console.log("Auth:", {
|
||||
currentUserId: currentUserId,
|
||||
currentSessionId: currentSessionId,
|
||||
currentBotId: currentBotId,
|
||||
currentBotName: currentBotName,
|
||||
});
|
||||
connectWebSocket();
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.error("Auth failed:", e);
|
||||
notify("Failed to connect to chat server", "error");
|
||||
setTimeout(initChat, 3000);
|
||||
setTimeout(proceedWithChatInit, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<!-- Drive - File Management v1.0 -->
|
||||
<link rel="stylesheet" href="drive/drive.css" />
|
||||
<link rel="stylesheet" href="/suite/drive/drive.css" />
|
||||
|
||||
<div class="drive-container" id="drive-app">
|
||||
<!-- Sidebar -->
|
||||
|
|
@ -348,4 +348,4 @@
|
|||
<!-- Context Menu (dynamically populated by JS) -->
|
||||
<div id="context-menu" class="context-menu hidden"></div>
|
||||
|
||||
<script src="drive/drive.js"></script>
|
||||
<script src="/suite/drive/drive.js"></script>
|
||||
|
|
|
|||
|
|
@ -324,7 +324,7 @@
|
|||
<a
|
||||
href="#tasks"
|
||||
class="app-card"
|
||||
hx-get="/suite/tasks/tasks.html"
|
||||
hx-get="/suite/tasks/autotask.html"
|
||||
hx-target="#main-content"
|
||||
hx-push-url="/#tasks"
|
||||
>
|
||||
|
|
|
|||
1865
ui/suite/index.html
1865
ui/suite/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -351,8 +351,8 @@
|
|||
this.clearAuth();
|
||||
this.emit("tokenExpired");
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
if (!currentPath.startsWith("/auth/")) {
|
||||
const currentPath = window.location.pathname + window.location.hash;
|
||||
if (!window.location.pathname.startsWith("/auth/")) {
|
||||
window.location.href =
|
||||
"/auth/login.html?expired=1&redirect=" +
|
||||
encodeURIComponent(currentPath);
|
||||
|
|
|
|||
|
|
@ -47,12 +47,15 @@ function applyProductConfig(config) {
|
|||
// Check if we have compiled_features info to filter even further
|
||||
// This ensures we don't show apps that are enabled in config but not compiled in binary
|
||||
if (config.compiled_features && Array.isArray(config.compiled_features)) {
|
||||
const compiledSet = new Set(config.compiled_features.map(f => f.toLowerCase()));
|
||||
effectiveApps = effectiveApps.filter(app =>
|
||||
const compiledSet = new Set(
|
||||
config.compiled_features.map((f) => f.toLowerCase()),
|
||||
);
|
||||
effectiveApps = effectiveApps.filter(
|
||||
(app) =>
|
||||
compiledSet.has(app.toLowerCase()) ||
|
||||
app.toLowerCase() === 'settings' ||
|
||||
app.toLowerCase() === 'auth' ||
|
||||
app.toLowerCase() === 'admin' // Admin usually contains settings which is always there
|
||||
app.toLowerCase() === "settings" ||
|
||||
app.toLowerCase() === "auth" ||
|
||||
app.toLowerCase() === "admin", // Admin usually contains settings which is always there
|
||||
);
|
||||
|
||||
// Also call a helper to hide UI elements for non-compiled features explicitly
|
||||
|
|
@ -61,6 +64,33 @@ function applyProductConfig(config) {
|
|||
}
|
||||
|
||||
filterAppsByConfig(effectiveApps);
|
||||
|
||||
// Check if there are any visible apps after filtering
|
||||
const hasVisibleApps = effectiveApps.length > 0;
|
||||
|
||||
// Hide apps menu button if menu launcher is disabled or if there are no apps to show
|
||||
if (config.menu_launcher_enabled === false || !hasVisibleApps) {
|
||||
const appsButton = document.getElementById("appsButton");
|
||||
if (appsButton) {
|
||||
appsButton.style.display = "none";
|
||||
}
|
||||
const appsMenuContainer = document.querySelector(".apps-menu-container");
|
||||
if (appsMenuContainer) {
|
||||
appsMenuContainer.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide omnibox if search is disabled
|
||||
if (config.search_enabled === false) {
|
||||
const omnibox = document.getElementById("omnibox");
|
||||
if (omnibox) {
|
||||
omnibox.style.display = "none";
|
||||
}
|
||||
const headerCenter = document.querySelector(".header-center");
|
||||
if (headerCenter) {
|
||||
headerCenter.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Apply custom logo
|
||||
|
|
@ -95,18 +125,22 @@ function applyProductConfig(config) {
|
|||
// Hide UI elements that require features not compiled in the binary
|
||||
function hideNonCompiledUI(compiledSet) {
|
||||
// Hide elements with data-feature attribute that aren't in compiled set
|
||||
document.querySelectorAll('[data-feature]').forEach(el => {
|
||||
const feature = el.getAttribute('data-feature').toLowerCase();
|
||||
document.querySelectorAll("[data-feature]").forEach((el) => {
|
||||
const feature = el.getAttribute("data-feature").toLowerCase();
|
||||
// Allow settings/admin as they are usually core
|
||||
if (!compiledSet.has(feature) && feature !== 'settings' && feature !== 'admin') {
|
||||
el.style.display = 'none';
|
||||
el.classList.add('hidden-uncompiled');
|
||||
if (
|
||||
!compiledSet.has(feature) &&
|
||||
feature !== "settings" &&
|
||||
feature !== "admin"
|
||||
) {
|
||||
el.style.display = "none";
|
||||
el.classList.add("hidden-uncompiled");
|
||||
}
|
||||
});
|
||||
|
||||
// Also look for specific sections that might map to features
|
||||
// e.g. .feature-mail, .feature-meet classes
|
||||
compiledSet.forEach(feature => {
|
||||
compiledSet.forEach((feature) => {
|
||||
// This loop defines what IS available.
|
||||
// Logic should be inverse: find all feature- classes and hide if not in set
|
||||
// But scanning all classes is expensive.
|
||||
|
|
|
|||
135
ui/suite/js/error-reporter.js
Normal file
135
ui/suite/js/error-reporter.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
(function() {
|
||||
'use strict';
|
||||
|
||||
const MAX_ERRORS = 50;
|
||||
const REPORT_ENDPOINT = '/api/client-errors';
|
||||
let errorQueue = [];
|
||||
let isReporting = false;
|
||||
|
||||
function formatError(error, context = {}) {
|
||||
return {
|
||||
type: error.name || 'Error',
|
||||
message: error.message || String(error),
|
||||
stack: error.stack,
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
timestamp: new Date().toISOString(),
|
||||
context: context
|
||||
};
|
||||
}
|
||||
|
||||
async function reportErrors() {
|
||||
if (isReporting || errorQueue.length === 0) return;
|
||||
|
||||
isReporting = true;
|
||||
const errorsToReport = errorQueue.splice(0, MAX_ERRORS);
|
||||
errorQueue = [];
|
||||
|
||||
try {
|
||||
const response = await fetch(REPORT_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ errors: errorsToReport })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[ErrorReporter] Failed to send errors:', response.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ErrorReporter] Failed to send errors:', e.message);
|
||||
errorQueue.unshift(...errorsToReport);
|
||||
} finally {
|
||||
isReporting = false;
|
||||
|
||||
if (errorQueue.length > 0) {
|
||||
setTimeout(reportErrors, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queueError(errorData) {
|
||||
errorQueue.push(errorData);
|
||||
if (errorQueue.length >= 10) {
|
||||
reportErrors();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
const errorData = formatError(event.error || new Error(event.message), {
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
});
|
||||
queueError(errorData);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const errorData = formatError(event.reason || new Error(String(event.reason)), {
|
||||
type: 'unhandledRejection'
|
||||
});
|
||||
queueError(errorData);
|
||||
});
|
||||
|
||||
window.ErrorReporter = {
|
||||
report: function(error, context) {
|
||||
queueError(formatError(error, context));
|
||||
},
|
||||
flush: function() {
|
||||
reportErrors();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'complete') {
|
||||
setTimeout(reportErrors, 1000);
|
||||
} else {
|
||||
window.addEventListener('load', () => {
|
||||
setTimeout(reportErrors, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[ErrorReporter] Client-side error reporting initialized');
|
||||
|
||||
window.NavigationLogger = {
|
||||
log: function(from, to, method) {
|
||||
const navEvent = {
|
||||
type: 'navigation',
|
||||
from: from,
|
||||
to: to,
|
||||
method: method,
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
queueError({
|
||||
name: 'Navigation',
|
||||
message: `${method}: ${from} -> ${to}`,
|
||||
stack: undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.body.addEventListener('click', function(e) {
|
||||
const target = e.target.closest('[data-section]');
|
||||
if (target) {
|
||||
const section = target.getAttribute('data-section');
|
||||
const currentHash = window.location.hash.slice(1) || '';
|
||||
if (section !== currentHash) {
|
||||
setTimeout(() => {
|
||||
window.NavigationLogger.log(currentHash || 'home', section, 'click');
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
window.addEventListener('hashchange', function(e) {
|
||||
const oldURL = new URL(e.oldURL);
|
||||
const newURL = new URL(e.newURL);
|
||||
const fromHash = oldURL.hash.slice(1) || '';
|
||||
const toHash = newURL.hash.slice(1) || '';
|
||||
window.NavigationLogger.log(fromHash || 'home', toHash, 'hashchange');
|
||||
});
|
||||
|
||||
console.log('[NavigationLogger] Navigation tracking initialized');
|
||||
})();
|
||||
|
|
@ -203,7 +203,12 @@
|
|||
|
||||
// Handle WebSocket messages
|
||||
function handleWebSocketMessage(message) {
|
||||
switch (message.type) {
|
||||
const messageType = message.type || message.event;
|
||||
|
||||
// Debug logging
|
||||
console.log("handleWebSocketMessage called with:", { messageType, message });
|
||||
|
||||
switch (messageType) {
|
||||
case "message":
|
||||
appendMessage(message);
|
||||
break;
|
||||
|
|
@ -216,8 +221,28 @@
|
|||
case "suggestion":
|
||||
addSuggestion(message.text);
|
||||
break;
|
||||
case "change_theme":
|
||||
console.log("Processing change_theme event, not appending to chat");
|
||||
if (message.data) {
|
||||
ThemeManager.setThemeFromServer(message.data);
|
||||
|
||||
if (message.data.color1 || message.data.color2) {
|
||||
const root = document.documentElement;
|
||||
if (message.data.color1)
|
||||
root.style.setProperty("--color1", message.data.color1);
|
||||
if (message.data.color2)
|
||||
root.style.setProperty("--color2", message.data.color2);
|
||||
}
|
||||
}
|
||||
return; // Don't append theme events to chat
|
||||
default:
|
||||
console.log("Unknown message type:", message.type);
|
||||
// Only append unknown message types to chat if they have text content
|
||||
if (message.text || message.content) {
|
||||
console.log("Unknown message type, treating as chat message:", messageType);
|
||||
appendMessage(message);
|
||||
} else {
|
||||
console.log("Unknown message type:", messageType, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -295,6 +295,12 @@
|
|||
});
|
||||
|
||||
window.addEventListener("gb:auth:expired", function (event) {
|
||||
// Check if current bot is public - if so, skip redirect
|
||||
if (window.__BOT_IS_PUBLIC__ === true) {
|
||||
console.log("[GBSecurity] Bot is public, skipping auth redirect");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[GBSecurity] Auth expired, clearing tokens and redirecting",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ window.Suite = {
|
|||
description: "",
|
||||
actions: [],
|
||||
searchable: true,
|
||||
...config
|
||||
...config,
|
||||
});
|
||||
|
||||
// Trigger UI update if Omnibox is initialized
|
||||
if (typeof Omnibox !== 'undefined' && Omnibox.isActive) {
|
||||
if (typeof Omnibox !== "undefined" && Omnibox.isActive) {
|
||||
Omnibox.updateActions();
|
||||
}
|
||||
},
|
||||
|
|
@ -36,7 +36,7 @@ window.Suite = {
|
|||
getContextActions(contextId) {
|
||||
const app = this.apps.get(contextId);
|
||||
return app ? app.actions : null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
|
|
@ -55,13 +55,9 @@ const Omnibox = {
|
|||
this.backdrop = document.getElementById("omniboxBackdrop");
|
||||
this.results = document.getElementById("omniboxResults");
|
||||
this.chat = document.getElementById("omniboxChat");
|
||||
this.chatMessages = document.getElementById(
|
||||
"omniboxChatMessages",
|
||||
);
|
||||
this.chatInput =
|
||||
document.getElementById("omniboxChatInput");
|
||||
this.modeToggle =
|
||||
document.getElementById("omniboxModeToggle");
|
||||
this.chatMessages = document.getElementById("omniboxChatMessages");
|
||||
this.chatInput = document.getElementById("omniboxChatInput");
|
||||
this.modeToggle = document.getElementById("omniboxModeToggle");
|
||||
|
||||
this.bindEvents();
|
||||
},
|
||||
|
|
@ -77,9 +73,7 @@ const Omnibox = {
|
|||
);
|
||||
|
||||
// Keyboard navigation
|
||||
this.input.addEventListener("keydown", (e) =>
|
||||
this.handleKeydown(e),
|
||||
);
|
||||
this.input.addEventListener("keydown", (e) => this.handleKeydown(e));
|
||||
this.chatInput?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -94,12 +88,8 @@ const Omnibox = {
|
|||
});
|
||||
|
||||
// Action buttons
|
||||
document
|
||||
.querySelectorAll(".omnibox-action")
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener("click", () =>
|
||||
this.handleAction(btn),
|
||||
);
|
||||
document.querySelectorAll(".omnibox-action").forEach((btn) => {
|
||||
btn.addEventListener("click", () => this.handleAction(btn));
|
||||
});
|
||||
|
||||
// Send button
|
||||
|
|
@ -115,9 +105,7 @@ const Omnibox = {
|
|||
// Expand button
|
||||
document
|
||||
.getElementById("omniboxExpandBtn")
|
||||
?.addEventListener("click", () =>
|
||||
this.expandToFullChat(),
|
||||
);
|
||||
?.addEventListener("click", () => this.expandToFullChat());
|
||||
|
||||
// Global shortcut (Cmd+K / Ctrl+K)
|
||||
document.addEventListener("keydown", (e) => {
|
||||
|
|
@ -159,17 +147,11 @@ const Omnibox = {
|
|||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.min(
|
||||
this.selectedIndex + 1,
|
||||
actions.length - 1,
|
||||
);
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, actions.length - 1);
|
||||
this.updateSelection(actions);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.selectedIndex = Math.max(
|
||||
this.selectedIndex - 1,
|
||||
0,
|
||||
);
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection(actions);
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
|
|
@ -185,17 +167,13 @@ const Omnibox = {
|
|||
|
||||
updateSelection(actions) {
|
||||
actions.forEach((a, i) => {
|
||||
a.classList.toggle(
|
||||
"selected",
|
||||
i === this.selectedIndex,
|
||||
);
|
||||
a.classList.toggle("selected", i === this.selectedIndex);
|
||||
});
|
||||
},
|
||||
|
||||
updateActions() {
|
||||
const currentApp = this.getCurrentApp();
|
||||
const actionsContainer =
|
||||
document.getElementById("omniboxActions");
|
||||
const actionsContainer = document.getElementById("omniboxActions");
|
||||
|
||||
const contextActions = {
|
||||
chat: [
|
||||
|
|
@ -362,12 +340,8 @@ const Omnibox = {
|
|||
.join("");
|
||||
|
||||
// Rebind events
|
||||
actionsContainer
|
||||
.querySelectorAll(".omnibox-action")
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener("click", () =>
|
||||
this.handleAction(btn),
|
||||
);
|
||||
actionsContainer.querySelectorAll(".omnibox-action").forEach((btn) => {
|
||||
btn.addEventListener("click", () => this.handleAction(btn));
|
||||
});
|
||||
|
||||
this.selectedIndex = 0;
|
||||
|
|
@ -398,9 +372,7 @@ const Omnibox = {
|
|||
|
||||
navigateTo(target) {
|
||||
this.close();
|
||||
const link = document.querySelector(
|
||||
`a[data-section="${target}"]`,
|
||||
);
|
||||
const link = document.querySelector(`a[data-section="${target}"]`);
|
||||
if (link) {
|
||||
link.click();
|
||||
}
|
||||
|
|
@ -436,32 +408,24 @@ const Omnibox = {
|
|||
},
|
||||
|
||||
showDefaultActions() {
|
||||
document.getElementById(
|
||||
"searchResultsSection",
|
||||
).style.display = "none";
|
||||
document.getElementById("searchResultsSection").style.display = "none";
|
||||
this.updateActions();
|
||||
},
|
||||
|
||||
searchContent(query) {
|
||||
// Show search results section
|
||||
const resultsSection = document.getElementById(
|
||||
"searchResultsSection",
|
||||
);
|
||||
const resultsList =
|
||||
document.getElementById("searchResultsList");
|
||||
const resultsSection = document.getElementById("searchResultsSection");
|
||||
const resultsList = document.getElementById("searchResultsList");
|
||||
|
||||
resultsSection.style.display = "block";
|
||||
|
||||
// Update first action to be "Ask about: query"
|
||||
const actionsContainer =
|
||||
document.getElementById("omniboxActions");
|
||||
const firstAction =
|
||||
actionsContainer.querySelector(".omnibox-action");
|
||||
const actionsContainer = document.getElementById("omniboxActions");
|
||||
const firstAction = actionsContainer.querySelector(".omnibox-action");
|
||||
if (firstAction) {
|
||||
firstAction.dataset.action = "chat";
|
||||
firstAction.dataset.query = query;
|
||||
firstAction.querySelector(".action-icon").textContent =
|
||||
"💬";
|
||||
firstAction.querySelector(".action-icon").textContent = "💬";
|
||||
firstAction.querySelector(".action-text").textContent =
|
||||
`Ask: "${query.substring(0, 30)}${query.length > 30 ? "..." : ""}"`;
|
||||
}
|
||||
|
|
@ -485,12 +449,8 @@ const Omnibox = {
|
|||
'<div class="no-results">No results found. Try asking the bot!</div>';
|
||||
|
||||
// Bind click events
|
||||
resultsList
|
||||
.querySelectorAll(".omnibox-result")
|
||||
.forEach((btn) => {
|
||||
btn.addEventListener("click", () =>
|
||||
this.navigateTo(btn.dataset.target),
|
||||
);
|
||||
resultsList.querySelectorAll(".omnibox-result").forEach((btn) => {
|
||||
btn.addEventListener("click", () => this.navigateTo(btn.dataset.target));
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -581,17 +541,16 @@ const Omnibox = {
|
|||
title: "Settings",
|
||||
description: "App settings",
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
// Add plugin apps
|
||||
const pluginApps = window.Suite.getAllApps()
|
||||
.filter(app => app.searchable)
|
||||
.map(app => ({
|
||||
.filter((app) => app.searchable)
|
||||
.map((app) => ({
|
||||
target: app.id,
|
||||
icon: app.icon || "📦",
|
||||
title: app.title || app.id,
|
||||
description: app.description || "App plugin"
|
||||
description: app.description || "App plugin",
|
||||
}));
|
||||
|
||||
const allItems = items.concat(pluginApps);
|
||||
|
|
@ -641,9 +600,7 @@ const Omnibox = {
|
|||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.addMessage(
|
||||
data.reply ||
|
||||
data.message ||
|
||||
"I received your message.",
|
||||
data.reply || data.message || "I received your message.",
|
||||
"bot",
|
||||
);
|
||||
|
||||
|
|
@ -660,10 +617,7 @@ const Omnibox = {
|
|||
} catch (error) {
|
||||
this.removeTypingIndicator(typingId);
|
||||
// Fallback response when API is not available
|
||||
this.addMessage(
|
||||
this.getFallbackResponse(message),
|
||||
"bot",
|
||||
);
|
||||
this.addMessage(this.getFallbackResponse(message), "bot");
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -697,8 +651,7 @@ const Omnibox = {
|
|||
<div class="message-content">${this.escapeHtml(text)}</div>
|
||||
`;
|
||||
this.chatMessages.appendChild(msgDiv);
|
||||
this.chatMessages.scrollTop =
|
||||
this.chatMessages.scrollHeight;
|
||||
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
|
||||
|
||||
this.chatHistory.push({ role: sender, content: text });
|
||||
},
|
||||
|
|
@ -715,8 +668,7 @@ const Omnibox = {
|
|||
</div>
|
||||
`;
|
||||
this.chatMessages.appendChild(typingDiv);
|
||||
this.chatMessages.scrollTop =
|
||||
this.chatMessages.scrollHeight;
|
||||
this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
|
||||
return id;
|
||||
},
|
||||
|
||||
|
|
@ -727,18 +679,13 @@ const Omnibox = {
|
|||
|
||||
handleBotAction(action) {
|
||||
if (action.navigate) {
|
||||
setTimeout(
|
||||
() => this.navigateTo(action.navigate),
|
||||
1000,
|
||||
);
|
||||
setTimeout(() => this.navigateTo(action.navigate), 1000);
|
||||
}
|
||||
},
|
||||
|
||||
expandToFullChat() {
|
||||
this.close();
|
||||
const chatLink = document.querySelector(
|
||||
'a[data-section="chat"]',
|
||||
);
|
||||
const chatLink = document.querySelector('a[data-section="chat"]');
|
||||
if (chatLink) chatLink.click();
|
||||
},
|
||||
|
||||
|
|
@ -751,13 +698,57 @@ const Omnibox = {
|
|||
|
||||
// Initialize Omnibox when DOM is ready
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Detect bot name from pathname (e.g., /edu -> bot_name = "edu")
|
||||
const detectBotFromPath = () => {
|
||||
const pathname = window.location.pathname;
|
||||
// Remove leading/trailing slashes and get first segment
|
||||
const segments = pathname.replace(/^\/|\/$/g, "").split("/");
|
||||
const firstSegment = segments[0];
|
||||
|
||||
// If first segment is not a known route, treat it as bot name
|
||||
const knownRoutes = ["suite", "auth", "api", "static", "public"];
|
||||
if (firstSegment && !knownRoutes.includes(firstSegment)) {
|
||||
return firstSegment;
|
||||
}
|
||||
return "default";
|
||||
};
|
||||
|
||||
// Set global bot name
|
||||
window.__INITIAL_BOT_NAME__ = detectBotFromPath();
|
||||
console.log(`🤖 Bot detected from path: ${window.__INITIAL_BOT_NAME__}`);
|
||||
|
||||
// Check if bot is public to skip authentication
|
||||
const checkBotPublicStatus = async () => {
|
||||
try {
|
||||
const botName = window.__INITIAL_BOT_NAME__;
|
||||
if (!botName || botName === "default") return;
|
||||
|
||||
const response = await fetch(
|
||||
`/api/bot/config?bot_name=${encodeURIComponent(botName)}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const config = await response.json();
|
||||
if (config.public === true) {
|
||||
window.__BOT_IS_PUBLIC__ = true;
|
||||
console.log(
|
||||
`✅ Bot '${botName}' is public - authentication not required`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to check bot public status:", e);
|
||||
}
|
||||
};
|
||||
|
||||
Omnibox.init();
|
||||
console.log("🚀 Initializing General Bots with HTMX...");
|
||||
|
||||
// Check bot public status early
|
||||
checkBotPublicStatus();
|
||||
|
||||
// Hide loading overlay
|
||||
setTimeout(() => {
|
||||
const loadingOverlay =
|
||||
document.getElementById("loadingOverlay");
|
||||
const loadingOverlay = document.getElementById("loadingOverlay");
|
||||
if (loadingOverlay) {
|
||||
loadingOverlay.classList.add("hidden");
|
||||
}
|
||||
|
|
@ -775,15 +766,11 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const isOpen = appsDropdown.classList.toggle("show");
|
||||
appsBtn.setAttribute("aria-expanded", isOpen);
|
||||
// Close settings panel
|
||||
if (settingsPanel)
|
||||
settingsPanel.classList.remove("show");
|
||||
if (settingsPanel) settingsPanel.classList.remove("show");
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!appsDropdown.contains(e.target) &&
|
||||
!appsBtn.contains(e.target)
|
||||
) {
|
||||
if (!appsDropdown.contains(e.target) && !appsBtn.contains(e.target)) {
|
||||
appsDropdown.classList.remove("show");
|
||||
appsBtn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
|
@ -813,8 +800,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Theme selection handling
|
||||
const themeOptions = document.querySelectorAll(".theme-option");
|
||||
const savedTheme =
|
||||
localStorage.getItem("gb-theme") || "sentient";
|
||||
const savedTheme = localStorage.getItem("gb-theme") || "sentient";
|
||||
|
||||
// Apply saved theme
|
||||
document.body.setAttribute("data-theme", savedTheme);
|
||||
|
|
@ -827,9 +813,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const theme = option.getAttribute("data-theme");
|
||||
document.body.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("gb-theme", theme);
|
||||
themeOptions.forEach((o) =>
|
||||
o.classList.remove("active"),
|
||||
);
|
||||
themeOptions.forEach((o) => o.classList.remove("active"));
|
||||
option.classList.add("active");
|
||||
|
||||
// Update theme-color meta tag
|
||||
|
|
@ -841,14 +825,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
orange: "#f97316",
|
||||
sentient: "#d4f505",
|
||||
};
|
||||
const metaTheme = document.querySelector(
|
||||
'meta[name="theme-color"]',
|
||||
);
|
||||
const metaTheme = document.querySelector('meta[name="theme-color"]');
|
||||
if (metaTheme) {
|
||||
metaTheme.setAttribute(
|
||||
"content",
|
||||
themeColors[theme] || "#d4f505",
|
||||
);
|
||||
metaTheme.setAttribute("content", themeColors[theme] || "#d4f505");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -959,17 +938,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Validate target exists before triggering HTMX load
|
||||
if (!mainContent) {
|
||||
console.warn(
|
||||
"handleHashChange: #main-content not found, skipping load",
|
||||
);
|
||||
console.warn("handleHashChange: #main-content not found, skipping load");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if main-content is in the DOM
|
||||
if (!document.body.contains(mainContent)) {
|
||||
console.warn(
|
||||
"handleHashChange: #main-content not in DOM, skipping load",
|
||||
);
|
||||
console.warn("handleHashChange: #main-content not in DOM, skipping load");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -984,8 +959,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
// Debounce the load to prevent rapid double-requests
|
||||
pendingLoadTimeout = setTimeout(() => {
|
||||
// Re-check if section changed during debounce
|
||||
const currentHash =
|
||||
window.location.hash.slice(1) || "chat";
|
||||
const currentHash = window.location.hash.slice(1) || "chat";
|
||||
if (currentLoadedSection === currentHash) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1005,10 +979,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
swap: "innerHTML",
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
"handleHashChange: HTMX ajax error:",
|
||||
e,
|
||||
);
|
||||
console.warn("handleHashChange: HTMX ajax error:", e);
|
||||
currentLoadedSection = null;
|
||||
isLoadingSection = false;
|
||||
}
|
||||
|
|
@ -1019,10 +990,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Listen for HTMX swaps to track loaded sections and prevent duplicates
|
||||
document.body.addEventListener("htmx:afterSwap", (event) => {
|
||||
if (
|
||||
event.detail.target &&
|
||||
event.detail.target.id === "main-content"
|
||||
) {
|
||||
if (event.detail.target && event.detail.target.id === "main-content") {
|
||||
const hash = window.location.hash.slice(1) || "chat";
|
||||
currentLoadedSection = hash;
|
||||
isLoadingSection = false;
|
||||
|
|
@ -1031,27 +999,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Reset tracking on swap errors
|
||||
document.body.addEventListener("htmx:swapError", (event) => {
|
||||
if (
|
||||
event.detail.target &&
|
||||
event.detail.target.id === "main-content"
|
||||
) {
|
||||
if (event.detail.target && event.detail.target.id === "main-content") {
|
||||
isLoadingSection = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Also listen for response errors
|
||||
document.body.addEventListener(
|
||||
"htmx:responseError",
|
||||
(event) => {
|
||||
if (
|
||||
event.detail.target &&
|
||||
event.detail.target.id === "main-content"
|
||||
) {
|
||||
document.body.addEventListener("htmx:responseError", (event) => {
|
||||
if (event.detail.target && event.detail.target.id === "main-content") {
|
||||
isLoadingSection = false;
|
||||
currentLoadedSection = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Load initial content based on hash or default to chat
|
||||
window.addEventListener("hashchange", handleHashChange);
|
||||
|
|
@ -1082,17 +1041,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const list = document.getElementById("notificationsList");
|
||||
const btn = document.getElementById("notificationsBtn");
|
||||
const panel = document.getElementById("notificationsPanel");
|
||||
const clearBtn = document.getElementById(
|
||||
"clearNotificationsBtn",
|
||||
);
|
||||
const clearBtn = document.getElementById("clearNotificationsBtn");
|
||||
|
||||
function updateBadge() {
|
||||
if (badge) {
|
||||
if (notifications.length > 0) {
|
||||
badge.textContent =
|
||||
notifications.length > 99
|
||||
? "99+"
|
||||
: notifications.length;
|
||||
notifications.length > 99 ? "99+" : notifications.length;
|
||||
badge.style.display = "flex";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
|
|
@ -1172,10 +1127,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!panel.contains(e.target) &&
|
||||
!btn.contains(e.target)
|
||||
) {
|
||||
if (!panel.contains(e.target) && !btn.contains(e.target)) {
|
||||
panel.classList.remove("show");
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
|
@ -1241,9 +1193,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
? "🔴"
|
||||
: "🟡",
|
||||
title:
|
||||
"Connection " +
|
||||
status.charAt(0).toUpperCase() +
|
||||
status.slice(1),
|
||||
"Connection " + status.charAt(0).toUpperCase() + status.slice(1),
|
||||
message: message || "",
|
||||
});
|
||||
},
|
||||
|
|
@ -1256,8 +1206,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
if (e.altKey && !e.ctrlKey && !e.shiftKey) {
|
||||
const num = parseInt(e.key);
|
||||
if (num >= 1 && num <= 9) {
|
||||
const items =
|
||||
document.querySelectorAll(".app-item");
|
||||
const items = document.querySelectorAll(".app-item");
|
||||
if (items[num - 1]) {
|
||||
items[num - 1].click();
|
||||
e.preventDefault();
|
||||
|
|
@ -1281,21 +1230,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
const userName = document.getElementById("userName");
|
||||
const userEmail = document.getElementById("userEmail");
|
||||
const userAvatar = document.getElementById("userAvatar");
|
||||
const userAvatarLarge =
|
||||
document.getElementById("userAvatarLarge");
|
||||
const userAvatarLarge = document.getElementById("userAvatarLarge");
|
||||
const authAction = document.getElementById("authAction");
|
||||
const authText = document.getElementById("authText");
|
||||
const authIcon = document.getElementById("authIcon");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
const appsButton = document.getElementById("appsButton");
|
||||
const notificationsBtn = document.getElementById("notificationsBtn");
|
||||
|
||||
const displayName =
|
||||
user.display_name ||
|
||||
user.first_name ||
|
||||
user.username ||
|
||||
"User";
|
||||
user.display_name || user.first_name || user.username || "User";
|
||||
const email = user.email || "";
|
||||
const initial = (
|
||||
displayName.charAt(0) || "U"
|
||||
).toUpperCase();
|
||||
const initial = (displayName.charAt(0) || "U").toUpperCase();
|
||||
|
||||
console.log("Updating user UI:", displayName, email);
|
||||
|
||||
|
|
@ -1328,14 +1274,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
authIcon.innerHTML =
|
||||
'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>';
|
||||
}
|
||||
|
||||
if (settingsBtn) settingsBtn.style.display = "";
|
||||
if (appsButton) appsButton.style.display = "";
|
||||
if (notificationsBtn) notificationsBtn.style.display = "";
|
||||
}
|
||||
|
||||
function loadUserProfile() {
|
||||
var token =
|
||||
localStorage.getItem("gb-access-token") ||
|
||||
sessionStorage.getItem("gb-access-token");
|
||||
|
||||
if (!token) {
|
||||
console.log("No auth token found");
|
||||
console.log("No auth token found - user is signed out");
|
||||
updateSignedOutUI();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1348,22 +1300,60 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
if (!res.ok) {
|
||||
console.log("User not authenticated");
|
||||
updateSignedOutUI();
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function (user) {
|
||||
console.log("User profile loaded:", user);
|
||||
updateUserUI(user);
|
||||
localStorage.setItem(
|
||||
"gb-user-data",
|
||||
JSON.stringify(user),
|
||||
);
|
||||
localStorage.setItem("gb-user-data", JSON.stringify(user));
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.log("Failed to load user profile:", err);
|
||||
updateSignedOutUI();
|
||||
});
|
||||
}
|
||||
|
||||
function updateSignedOutUI() {
|
||||
const userName = document.getElementById("userName");
|
||||
const userEmail = document.getElementById("userEmail");
|
||||
const userAvatar = document.getElementById("userAvatar");
|
||||
const userAvatarLarge = document.getElementById("userAvatarLarge");
|
||||
const authAction = document.getElementById("authAction");
|
||||
const authText = document.getElementById("authText");
|
||||
const authIcon = document.getElementById("authIcon");
|
||||
const settingsBtn = document.getElementById("settingsBtn");
|
||||
const appsButton = document.getElementById("appsButton");
|
||||
const notificationsBtn = document.getElementById("notificationsBtn");
|
||||
|
||||
if (userName) userName.textContent = "User";
|
||||
if (userEmail) userEmail.textContent = "user@example.com";
|
||||
if (userAvatar) {
|
||||
const avatarSpan = userAvatar.querySelector("span");
|
||||
if (avatarSpan) avatarSpan.textContent = "U";
|
||||
}
|
||||
if (userAvatarLarge) userAvatarLarge.textContent = "U";
|
||||
|
||||
if (authAction) {
|
||||
authAction.href = "/auth/login.html";
|
||||
authAction.removeAttribute("onclick");
|
||||
authAction.style.color = "var(--primary)";
|
||||
}
|
||||
if (authText) authText.textContent = "Sign in";
|
||||
if (authIcon) {
|
||||
authIcon.innerHTML =
|
||||
'<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path><polyline points="10 17 15 12 21 12"></polyline><line x1="15" y1="12" x2="3" y2="12"></line>';
|
||||
}
|
||||
|
||||
if (settingsBtn) settingsBtn.style.display = "none";
|
||||
if (appsButton) appsButton.style.display = "none";
|
||||
if (notificationsBtn) notificationsBtn.style.display = "none";
|
||||
}
|
||||
|
||||
// Try to load cached user first
|
||||
var cachedUser = localStorage.getItem("gb-user-data");
|
||||
if (cachedUser) {
|
||||
|
|
@ -1377,10 +1367,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
|
||||
// Always fetch fresh user data
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener(
|
||||
"DOMContentLoaded",
|
||||
loadUserProfile,
|
||||
);
|
||||
document.addEventListener("DOMContentLoaded", loadUserProfile);
|
||||
} else {
|
||||
loadUserProfile();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1343,5 +1343,5 @@
|
|||
</div>
|
||||
</dialog>
|
||||
|
||||
<link rel="stylesheet" href="mail/mail.css" />
|
||||
<script src="mail/mail.js"></script>
|
||||
<link rel="stylesheet" href="/suite/mail/mail.css" />
|
||||
<script src="/suite/mail/mail.js"></script>
|
||||
|
|
|
|||
63
ui/suite/partials/navigation_menu.html
Normal file
63
ui/suite/partials/navigation_menu.html
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<div class="navigation-menu" id="navigation-menu">
|
||||
<nav class="nav-menu-content">
|
||||
<a href="/auth/login"
|
||||
class="nav-menu-item nav-sign-in"
|
||||
hx-get="/auth/login"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="window.location.href='/login'">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10 17 15 12 10 7"></polyline>
|
||||
<line x1="15" y1="12" x2="3" y2="12"></line>
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.navigation-menu {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
animation: slideIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.nav-menu-content {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.nav-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-sign-in {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.nav-sign-in:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.nav-sign-in svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<link rel="stylesheet" href="tasks/autotask.css" />
|
||||
<link rel="stylesheet" href="/suite/tasks/autotask.css" />
|
||||
|
||||
<div class="autotask-container" data-theme="sentient">
|
||||
<!-- Top Navigation Bar -->
|
||||
|
|
@ -478,6 +478,6 @@ Examples:
|
|||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<link rel="stylesheet" href="tasks/progress-panel.css" />
|
||||
<script src="tasks/progress-panel.js"></script>
|
||||
<script src="tasks/autotask.js"></script>
|
||||
<link rel="stylesheet" href="/suite/tasks/progress-panel.css" />
|
||||
<script src="/suite/tasks/progress-panel.js"></script>
|
||||
<script src="/suite/tasks/autotask.js"></script>
|
||||
|
|
|
|||
|
|
@ -1,303 +1,3 @@
|
|||
<link rel="stylesheet" href="tasks/tasks.css" />
|
||||
|
||||
<!-- =============================================================================
|
||||
TASKS APP - Autonomous Task Management
|
||||
Respects Theme Manager - No hardcoded theme
|
||||
============================================================================= -->
|
||||
|
||||
<div class="tasks-app">
|
||||
<!-- Hidden element to load stats on page load -->
|
||||
<div
|
||||
hx-get="/api/ui/tasks/stats"
|
||||
hx-trigger="load, taskCreated from:body"
|
||||
hx-swap="innerHTML"
|
||||
style="display: none"
|
||||
></div>
|
||||
|
||||
<!-- Status Filter Pills Row -->
|
||||
<div class="status-filter-row">
|
||||
<button
|
||||
class="filter-pill"
|
||||
data-filter="complete"
|
||||
hx-get="/api/ui/tasks?filter=complete"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">✓</span>
|
||||
<span class="pill-label" data-i18n="tasks-completed">Complete</span>
|
||||
<span class="pill-count" id="count-complete">-</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-pill active"
|
||||
data-filter="all"
|
||||
hx-get="/api/ui/tasks?filter=all"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">📋</span>
|
||||
<span class="pill-label" data-i18n="tasks-all">All Tasks</span>
|
||||
<span class="pill-count" id="count-all">-</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-pill"
|
||||
data-filter="active"
|
||||
hx-get="/api/ui/tasks?filter=active"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">⚡</span>
|
||||
<span class="pill-label" data-i18n="tasks-active"
|
||||
>Active Intents</span
|
||||
>
|
||||
<span class="pill-count" id="count-active">-</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-pill"
|
||||
data-filter="awaiting"
|
||||
hx-get="/api/ui/tasks?filter=awaiting"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">⏳</span>
|
||||
<span class="pill-label" data-i18n="tasks-awaiting"
|
||||
>Awaiting Decision</span
|
||||
>
|
||||
<span class="pill-count" id="count-awaiting">-</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-pill"
|
||||
data-filter="paused"
|
||||
hx-get="/api/ui/tasks?filter=paused"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">⏸</span>
|
||||
<span class="pill-label" data-i18n="tasks-paused">Paused</span>
|
||||
<span class="pill-count" id="count-paused">-</span>
|
||||
</button>
|
||||
<button
|
||||
class="filter-pill"
|
||||
data-filter="blocked"
|
||||
hx-get="/api/ui/tasks?filter=blocked"
|
||||
hx-target="#task-list"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<span class="pill-icon">⚠</span>
|
||||
<span class="pill-label" data-i18n="tasks-blocked"
|
||||
>Blocked/Issues</span
|
||||
>
|
||||
<span class="pill-count" id="count-blocked">-</span>
|
||||
</button>
|
||||
<div class="time-saved-badge">
|
||||
<span class="time-label" data-i18n="tasks-time-saved"
|
||||
>Active Time Saved:</span
|
||||
>
|
||||
<span class="time-value" id="time-saved-value">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Intent Input -->
|
||||
<div class="quick-intent-bar">
|
||||
<div class="intent-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="quick-intent-input"
|
||||
name="intent"
|
||||
class="quick-intent-input"
|
||||
placeholder="What would you like to do? e.g., 'create a CRM app' or 'remind me to call John tomorrow'"
|
||||
data-i18n-placeholder="tasks-input-placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
id="quick-intent-btn"
|
||||
class="btn-create-run"
|
||||
hx-post="/api/ui/autotask/create"
|
||||
hx-ext="json-enc"
|
||||
hx-include="#quick-intent-input"
|
||||
hx-target="#intent-result-hidden"
|
||||
hx-swap="none"
|
||||
hx-indicator="#intent-spinner"
|
||||
hx-timeout="300000"
|
||||
>
|
||||
<span class="btn-text">Create & Run</span>
|
||||
<span class="spinner" id="intent-spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="intent-result" class="intent-result"></div>
|
||||
<div id="intent-result-hidden" style="display: none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Two-Column Layout with Splitter -->
|
||||
<main class="tasks-main">
|
||||
<!-- Left Panel: Task Cards List -->
|
||||
<section class="tasks-list-panel">
|
||||
<div
|
||||
class="tasks-list-scroll"
|
||||
id="task-list"
|
||||
hx-get="/api/ui/tasks?filter=all"
|
||||
hx-trigger="load, taskCreated from:body throttle:2s"
|
||||
hx-swap="innerHTML transition:false"
|
||||
>
|
||||
<!-- Loading state - replaced by HTMX -->
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading tasks...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Splitter -->
|
||||
<div class="tasks-splitter" id="tasks-splitter"></div>
|
||||
|
||||
<!-- Right Panel: Task Detail -->
|
||||
<aside class="task-detail-panel" id="task-detail-panel">
|
||||
<!-- Detail content loaded dynamically -->
|
||||
<div class="detail-empty" id="detail-empty">
|
||||
<div class="empty-icon">📋</div>
|
||||
<h3 class="empty-title">Select a task</h3>
|
||||
<p class="empty-description">
|
||||
Click on a task from the list to view details
|
||||
</p>
|
||||
<div class="empty-info">
|
||||
<p>
|
||||
<strong>Bot Database:</strong> All apps share the same
|
||||
database tables.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Shared Resources:</strong> Schedulers, tools,
|
||||
and monitors work across all apps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic detail content -->
|
||||
<div
|
||||
id="task-detail-content"
|
||||
style="display: none"
|
||||
hx-get=""
|
||||
hx-trigger="taskSelected from:body"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
<!-- Loaded via HTMX when task selected -->
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Floating Progress Panel - Shows live task generation progress -->
|
||||
<div
|
||||
class="floating-progress-panel"
|
||||
id="floating-progress"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="floating-progress-header">
|
||||
<div class="floating-progress-title">
|
||||
<span class="progress-dot"></span>
|
||||
<span id="floating-task-name">Processing...</span>
|
||||
</div>
|
||||
<div class="floating-progress-actions">
|
||||
<button class="btn-minimize" onclick="minimizeFloatingProgress()">
|
||||
—
|
||||
</button>
|
||||
<button class="btn-close-float" onclick="closeFloatingProgress()">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="floating-progress-body">
|
||||
<div class="floating-progress-bar">
|
||||
<div
|
||||
class="floating-progress-fill"
|
||||
id="floating-progress-fill"
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="floating-progress-info">
|
||||
<span id="floating-progress-step">Starting...</span>
|
||||
<span id="floating-progress-percent">0%</span>
|
||||
</div>
|
||||
<div class="floating-progress-log" id="floating-progress-log">
|
||||
<!-- Live log entries appear here -->
|
||||
</div>
|
||||
<div class="floating-llm-terminal" id="floating-llm-terminal">
|
||||
<!-- LLM streaming output appears here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast Container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<!-- New Intent Modal -->
|
||||
<div class="modal" id="new-intent-modal" style="display: none">
|
||||
<div class="modal-backdrop" onclick="closeNewIntentModal()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Create New Intent</h3>
|
||||
<button class="btn-close" onclick="closeNewIntentModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="new-intent-form">
|
||||
<div class="form-group">
|
||||
<label for="intent-text"
|
||||
>What would you like to accomplish?</label
|
||||
>
|
||||
<textarea
|
||||
id="intent-text"
|
||||
name="intent"
|
||||
rows="4"
|
||||
placeholder="Describe what you want to create or automate..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="intent-priority">Priority</label>
|
||||
<select id="intent-priority" name="priority">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium" selected>Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="intent-mode">Execution Mode</label>
|
||||
<select id="intent-mode" name="mode">
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="supervised">Supervised</option>
|
||||
<option value="manual">Manual Approval</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeNewIntentModal()">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" onclick="submitNewIntent()">
|
||||
Create & Run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decision Modal -->
|
||||
<div class="modal" id="decision-modal" style="display: none">
|
||||
<div class="modal-backdrop" onclick="closeDecisionModal()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Make Decision</h3>
|
||||
<button class="btn-close" onclick="closeDecisionModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="decision-question" id="decision-question">
|
||||
<h4>Decision Required</h4>
|
||||
<p>Loading decision details...</p>
|
||||
</div>
|
||||
<div class="decision-options" id="decision-options">
|
||||
<!-- Options loaded dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" onclick="closeDecisionModal()">
|
||||
Cancel
|
||||
|
|
@ -312,4 +12,4 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="tasks/tasks.css" />
|
||||
<script src="/suite/tasks/tasks.js"></script>
|
||||
|
|
|
|||
|
|
@ -213,8 +213,10 @@ function setupIntentInputHandlers() {
|
|||
}
|
||||
|
||||
// Task polling for async task creation
|
||||
let activePollingTaskId = null;
|
||||
let pollingInterval = null;
|
||||
if (typeof activePollingTaskId === "undefined") {
|
||||
var activePollingTaskId = null;
|
||||
var pollingInterval = null;
|
||||
}
|
||||
|
||||
function startTaskPolling(taskId) {
|
||||
// Stop any existing polling
|
||||
|
|
@ -629,7 +631,9 @@ function handleWebSocketMessage(data) {
|
|||
}
|
||||
|
||||
// Store pending manifest updates for tasks whose elements aren't loaded yet
|
||||
const pendingManifestUpdates = new Map();
|
||||
if (typeof pendingManifestUpdates === "undefined") {
|
||||
var pendingManifestUpdates = new Map();
|
||||
}
|
||||
|
||||
function renderManifestProgress(
|
||||
taskId,
|
||||
|
|
@ -2759,8 +2763,9 @@ function formatTime(seconds) {
|
|||
// GLOBAL STYLES FOR TOAST ANIMATIONS
|
||||
// =============================================================================
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
if (typeof taskStyleElement === "undefined") {
|
||||
var taskStyleElement = document.createElement("style");
|
||||
taskStyleElement.textContent = `
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -2805,7 +2810,8 @@ style.textContent = `
|
|||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
document.head.appendChild(taskStyleElement);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GOALS, PENDING INFO, SCHEDULERS, MONITORS
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue