Compare commits

...

13 commits

Author SHA1 Message Date
e0504f3703 Create dedicated BotUI CI workflow
- New workflow for botui-only builds and deployments
- Trigger only on botui/**, botlib/** changes
- Build only botui package with embed-ui feature
- Deploy only botui binary
- Restart only ui service
- Separate cache key to avoid conflicts with botserver
2026-02-05 08:45:20 -03:00
91a750127c Fix ServeDir import compilation error 2026-02-05 08:00:37 -03:00
5618ed4367 Update: UI files and add error-reporter.js 2026-02-04 13:54:26 -03:00
5e10222a94 Fix login redirect to use absolute URL
- Use window.location.origin for redirect to ensure it works from any path
- Redirects to chat (#chat) after successful authentication
- Maintains support for custom redirect parameter
2026-02-04 13:20:14 -03:00
375b457f48 Fix login redirect to chat after authentication
- Change default redirect from '/' to '/#chat' after successful login
- Ensures users go directly to chat interface instead of root
2026-02-04 12:52:47 -03:00
e135ebf2e6 Hide voice icon, use light theme, add cursor blink
- Hide voice input button in chat interface
- Change default theme from 'sentient' to 'light'
- Add blinking cursor animation to chat input field
2026-02-04 09:56:24 -03:00
b69ea06ad3 Fix logged-out user menu - show only Sign in button
Removed Profile, Settings, and Help & Support items from user menu
when user is not authenticated. Now only Sign in button is visible.
2026-02-04 08:57:31 -03:00
bd49ee3892 Add logged-out navigation menu component
- Simplified menu showing only Sign in button
- Uses HTMX for authentication redirect
- Follows existing UI component patterns
- Responsive design with CSS variables
2026-02-04 00:03:10 -03:00
34d55825bc feat: Hide omnibox and apps menu based on product configuration
- Hide omnibox search mechanism when search_enabled=false in product config
- Hide apps menu launcher when menu_launcher_enabled=false or when no apps are visible
- Check effectiveApps (after filtering by compiled features) to determine if menu should be shown
- Automatically hide the apps menu button when there are no apps to display

This provides UI controls that respect the new search_enabled and menu_launcher_enabled
directives added to the .product configuration.
2026-01-29 23:56:00 -03:00
27e839f22a Fix bot_id routing: Extract bot name from URL path
- Add bot_name field to WsQuery struct
- Extract bot_name from URL path (e.g., /edu, /chat/edu)
- Pass bot_name to backend WebSocket URL
- Use URL path for bot identification instead of relying on client message
2026-01-28 17:17:36 -03:00
db0f0c1178 fix(ui): Use absolute paths for CSS/JS assets in suite apps to resolve loading errors 2026-01-28 16:26:09 -03:00
f4dcae288a chore: Revert default log level to info 2026-01-27 18:44:22 -03:00
bb8b35d885 chore: Set default internal log level to trace 2026-01-27 18:37:21 -03:00
26 changed files with 4658 additions and 3361 deletions

View file

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

View file

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

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

@ -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
![image](https://github.com/user-attachments/assets/477b7472-81d8-4e38-a541-70a7e2496a02)
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

View file

@ -1,4 +1,3 @@
use log::info;
use std::net::SocketAddr;

View file

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

View file

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

View file

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

View file

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

View file

@ -110,97 +110,97 @@
/* Messages Area */
#messages {
flex: 1;
overflow-y: auto;
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 16px;
scrollbar-width: thin;
scrollbar-color: var(--accent, #3b82f6) var(--surface, #1a1a24);
flex: 1;
overflow-y: auto;
padding: 20px 0;
display: flex;
flex-direction: column;
gap: 16px;
scrollbar-width: thin;
scrollbar-color: var(--accent, #3b82f6) var(--surface, #1a1a24);
}
/* Custom scrollbar for markers */
#messages::-webkit-scrollbar {
width: 6px;
width: 6px;
}
#messages::-webkit-scrollbar-track {
background: var(--surface, #1a1a24);
border-radius: 3px;
background: var(--surface, #1a1a24);
border-radius: 3px;
}
#messages::-webkit-scrollbar-thumb {
background: var(--accent, #3b82f6);
border-radius: 3px;
border: 1px solid var(--surface, #1a1a24);
background: var(--accent, #3b82f6);
border-radius: 3px;
border: 1px solid var(--surface, #1a1a24);
}
#messages::-webkit-scrollbar-thumb:hover {
background: var(--accent-hover, #2563eb);
background: var(--accent-hover, #2563eb);
}
/* Scrollbar markers container */
.scrollbar-markers {
position: absolute;
top: 0;
right: 2px;
width: 8px;
height: 100%;
pointer-events: none;
z-index: 10;
position: absolute;
top: 0;
right: 2px;
width: 8px;
height: 100%;
pointer-events: none;
z-index: 10;
}
.scrollbar-marker {
position: absolute;
right: 0;
width: 8px;
height: 8px;
background: var(--accent, #3b82f6);
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
transition: all 0.2s ease;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
z-index: 11;
position: absolute;
right: 0;
width: 8px;
height: 8px;
background: var(--accent, #3b82f6);
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
transition: all 0.2s ease;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
z-index: 11;
}
.scrollbar-marker:hover {
transform: scale(1.5);
background: var(--accent-hover, #2563eb);
box-shadow: 0 0 8px var(--accent, #3b82f6);
transform: scale(1.5);
background: var(--accent-hover, #2563eb);
box-shadow: 0 0 8px var(--accent, #3b82f6);
}
.scrollbar-marker.user-marker {
background: var(--accent, #3b82f6);
background: var(--accent, #3b82f6);
}
.scrollbar-marker.bot-marker {
background: var(--success, #22c55e);
background: var(--success, #22c55e);
}
.scrollbar-marker-tooltip {
position: absolute;
right: 12px;
transform: translateY(-50%);
background: var(--surface, #1a1a24);
border: 1px solid var(--border, #2a2a2a);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
color: var(--text, #ffffff);
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
pointer-events: none;
z-index: 12;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
position: absolute;
right: 12px;
transform: translateY(-50%);
background: var(--surface, #1a1a24);
border: 1px solid var(--border, #2a2a2a);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
color: var(--text, #ffffff);
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
pointer-events: none;
z-index: 12;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.scrollbar-marker:hover .scrollbar-marker-tooltip {
opacity: 1;
visibility: visible;
opacity: 1;
visibility: visible;
}
/* Message Styles */
@ -399,28 +399,28 @@ footer {
}
.mention-results {
overflow-y: auto;
max-height: 250px;
scrollbar-width: thin;
scrollbar-color: var(--accent, #3b82f6) transparent;
overflow-y: auto;
max-height: 250px;
scrollbar-width: thin;
scrollbar-color: var(--accent, #3b82f6) transparent;
}
.mention-results::-webkit-scrollbar {
width: 4px;
width: 4px;
}
.mention-results::-webkit-scrollbar-track {
background: transparent;
border-radius: 2px;
background: transparent;
border-radius: 2px;
}
.mention-results::-webkit-scrollbar-thumb {
background: var(--accent, #3b82f6);
border-radius: 2px;
background: var(--accent, #3b82f6);
border-radius: 2px;
}
.mention-results::-webkit-scrollbar-thumb:hover {
background: var(--accent-hover, #2563eb);
background: var(--accent-hover, #2563eb);
}
.mention-item {
@ -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;
}

View file

@ -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);
});
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View 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');
})();

View file

@ -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);
}
}
}

View file

@ -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",
);

View file

@ -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) {
@ -1372,15 +1362,12 @@ document.addEventListener("DOMContentLoaded", () => {
if (user && user.email) {
updateUserUI(user);
}
} catch (e) { }
} catch (e) {}
}
// Always fetch fresh user data
if (document.readyState === "loading") {
document.addEventListener(
"DOMContentLoaded",
loadUserProfile,
);
document.addEventListener("DOMContentLoaded", loadUserProfile);
} else {
loadUserProfile();
}

View file

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

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

View file

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

View file

@ -1,304 +1,4 @@
<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">
<div class="modal-footer">
<button class="btn-secondary" onclick="closeDecisionModal()">
Cancel
</button>
@ -312,4 +12,4 @@
</div>
</div>
<link rel="stylesheet" href="tasks/tasks.css" />
<script src="/suite/tasks/tasks.js"></script>

View file

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