Update: UI files and add error-reporter.js

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-02-04 13:54:26 -03:00
parent 5e10222a94
commit 5618ed4367
11 changed files with 1625 additions and 1299 deletions

View file

@ -7,10 +7,10 @@ use axum::{
http::{Request, StatusCode}, http::{Request, StatusCode},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{any, get}, routing::{any, get},
Router, Json, Router,
}; };
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info}; use log::{debug, error, info, warn};
#[cfg(feature = "embed-ui")] #[cfg(feature = "embed-ui")]
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::Deserialize; use serde::Deserialize;
@ -130,8 +130,98 @@ const ROOT_FILES: &[&str] = &[
"single.gbui", "single.gbui",
]; ];
pub async fn index() -> impl IntoResponse { pub async fn index(OriginalUri(uri): OriginalUri) -> Response {
serve_suite().await 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 { pub fn get_ui_root() -> PathBuf {
@ -196,7 +286,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 = { let raw_html_res = {
#[cfg(feature = "embed-ui")] #[cfg(feature = "embed-ui")]
{ {
@ -235,6 +325,32 @@ pub async fn serve_suite() -> impl IntoResponse {
#[allow(unused_mut)] // Mutable required for feature-gated blocks #[allow(unused_mut)] // Mutable required for feature-gated blocks
let mut html = raw_html; 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 // Core Apps
#[cfg(not(feature = "chat"))] #[cfg(not(feature = "chat"))]
{ {
@ -452,14 +568,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, StatusCode::OK,
axum::Json(serde_json::json!({ axum::Json(serde_json::json!({
"status": "ok", "status": "ok",
"botserver": "healthy",
"version": env!("CARGO_PKG_VERSION") "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> { fn extract_app_context(headers: &axum::http::HeaderMap, path: &str) -> Option<String> {
@ -588,6 +716,7 @@ async fn build_proxy_response(resp: reqwest::Response) -> Response<Body> {
fn create_api_router() -> Router<AppState> { fn create_api_router() -> Router<AppState> {
Router::new() Router::new()
.route("/health", get(api_health)) .route("/health", get(api_health))
.route("/client-error", axum::routing::post(handle_client_error))
.fallback(any(proxy_api)) .fallback(any(proxy_api))
} }
@ -598,6 +727,35 @@ struct WsQuery {
bot_name: Option<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)] #[derive(Debug, Default, Deserialize)]
struct OptionalWsQuery { struct OptionalWsQuery {
task_id: Option<String>, task_id: Option<String>,
@ -613,11 +771,14 @@ async fn ws_proxy(
let path_parts: Vec<&str> = uri.path().split('/').collect(); let path_parts: Vec<&str> = uri.path().split('/').collect();
let bot_name = params let bot_name = params
.bot_name .bot_name
.filter(|name| name != "ws" && !name.is_empty())
.or_else(|| { .or_else(|| {
// Try to extract from path like /edu or /app/edu // Try to extract from path like /edu or /app/edu
path_parts path_parts
.iter() .iter()
.find(|part| !part.is_empty() && *part != "chat" && *part != "app") .find(|part| {
!part.is_empty() && **part != "chat" && **part != "app" && **part != "ws"
})
.map(|s| s.to_string()) .map(|s| s.to_string())
}) })
.unwrap_or_else(|| "default".to_string()); .unwrap_or_else(|| "default".to_string());
@ -996,10 +1157,12 @@ fn add_static_routes(router: Router<AppState>, _suite_path: &Path) -> Router<App
#[cfg(not(feature = "embed-ui"))] #[cfg(not(feature = "embed-ui"))]
{ {
let mut r = router; let mut r = router;
// Serve suite directories ONLY through /suite/{dir} path // Serve suite directories at BOTH root level and /suite/{dir} path
// This prevents duplicate routes that cause ServeDir to return index.html for file requests // This allows HTML files to reference js/vendor/file.js directly
for dir in SUITE_DIRS { for dir in SUITE_DIRS {
let path = _suite_path.join(dir); let path = _suite_path.join(dir);
info!("Adding route for /{} -> {:?}", dir, path);
r = r.nest_service(&format!("/{dir}"), ServeDir::new(path.clone()));
info!("Adding route for /suite/{} -> {:?}", dir, path); info!("Adding route for /suite/{} -> {:?}", dir, path);
r = r.nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone())); r = r.nest_service(&format!("/suite/{dir}"), ServeDir::new(path.clone()));
} }
@ -1024,13 +1187,14 @@ pub fn configure_router() -> Router {
.nest("/ui", create_ui_router()) .nest("/ui", create_ui_router())
.nest("/ws", create_ws_router()) .nest("/ws", create_ws_router())
.nest("/apps", create_apps_router()) .nest("/apps", create_apps_router())
.route("/", get(index)) .route("/favicon.ico", get(serve_favicon));
.route("/minimal", get(serve_minimal))
.route("/suite", get(serve_suite))
.route("/favicon.ico", get(serve_favicon))
.nest_service("/auth", ServeDir::new(suite_path.join("auth")));
router = add_static_routes(router, &suite_path); 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

@ -10,6 +10,9 @@
<script src="/js/vendor/htmx-ws.js"></script> <script src="/js/vendor/htmx-ws.js"></script>
<script src="/js/vendor/htmx-json-enc.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 --> <!-- i18n -->
<script src="/js/i18n.js"></script> <script src="/js/i18n.js"></script>
@ -116,7 +119,7 @@
href="#tasks" href="#tasks"
class="app-item" class="app-item"
role="menuitem" role="menuitem"
hx-get="/tasks/tasks.html" hx-get="/suite/tasks/autotask.html"
hx-target="#main-content" hx-target="#main-content"
hx-push-url="true" hx-push-url="true"
> >

View file

@ -324,7 +324,7 @@
<a <a
href="#tasks" href="#tasks"
class="app-card" class="app-card"
hx-get="/suite/tasks/tasks.html" hx-get="/suite/tasks/autotask.html"
hx-target="#main-content" hx-target="#main-content"
hx-push-url="/#tasks" hx-push-url="/#tasks"
> >

View file

@ -351,8 +351,8 @@
this.clearAuth(); this.clearAuth();
this.emit("tokenExpired"); this.emit("tokenExpired");
const currentPath = window.location.pathname; const currentPath = window.location.pathname + window.location.hash;
if (!currentPath.startsWith("/auth/")) { if (!window.location.pathname.startsWith("/auth/")) {
window.location.href = window.location.href =
"/auth/login.html?expired=1&redirect=" + "/auth/login.html?expired=1&redirect=" +
encodeURIComponent(currentPath); encodeURIComponent(currentPath);

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 // Handle WebSocket messages
function handleWebSocketMessage(message) { 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": case "message":
appendMessage(message); appendMessage(message);
break; break;
@ -216,8 +221,28 @@
case "suggestion": case "suggestion":
addSuggestion(message.text); addSuggestion(message.text);
break; 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: 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) { 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( console.log(
"[GBSecurity] Auth expired, clearing tokens and redirecting", "[GBSecurity] Auth expired, clearing tokens and redirecting",
); );

View file

@ -15,11 +15,11 @@ window.Suite = {
description: "", description: "",
actions: [], actions: [],
searchable: true, searchable: true,
...config ...config,
}); });
// Trigger UI update if Omnibox is initialized // Trigger UI update if Omnibox is initialized
if (typeof Omnibox !== 'undefined' && Omnibox.isActive) { if (typeof Omnibox !== "undefined" && Omnibox.isActive) {
Omnibox.updateActions(); Omnibox.updateActions();
} }
}, },
@ -36,7 +36,7 @@ window.Suite = {
getContextActions(contextId) { getContextActions(contextId) {
const app = this.apps.get(contextId); const app = this.apps.get(contextId);
return app ? app.actions : null; return app ? app.actions : null;
} },
}; };
// ========================================== // ==========================================
@ -55,13 +55,9 @@ const Omnibox = {
this.backdrop = document.getElementById("omniboxBackdrop"); this.backdrop = document.getElementById("omniboxBackdrop");
this.results = document.getElementById("omniboxResults"); this.results = document.getElementById("omniboxResults");
this.chat = document.getElementById("omniboxChat"); this.chat = document.getElementById("omniboxChat");
this.chatMessages = document.getElementById( this.chatMessages = document.getElementById("omniboxChatMessages");
"omniboxChatMessages", this.chatInput = document.getElementById("omniboxChatInput");
); this.modeToggle = document.getElementById("omniboxModeToggle");
this.chatInput =
document.getElementById("omniboxChatInput");
this.modeToggle =
document.getElementById("omniboxModeToggle");
this.bindEvents(); this.bindEvents();
}, },
@ -77,9 +73,7 @@ const Omnibox = {
); );
// Keyboard navigation // Keyboard navigation
this.input.addEventListener("keydown", (e) => this.input.addEventListener("keydown", (e) => this.handleKeydown(e));
this.handleKeydown(e),
);
this.chatInput?.addEventListener("keydown", (e) => { this.chatInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault(); e.preventDefault();
@ -94,12 +88,8 @@ const Omnibox = {
}); });
// Action buttons // Action buttons
document document.querySelectorAll(".omnibox-action").forEach((btn) => {
.querySelectorAll(".omnibox-action") btn.addEventListener("click", () => this.handleAction(btn));
.forEach((btn) => {
btn.addEventListener("click", () =>
this.handleAction(btn),
);
}); });
// Send button // Send button
@ -115,9 +105,7 @@ const Omnibox = {
// Expand button // Expand button
document document
.getElementById("omniboxExpandBtn") .getElementById("omniboxExpandBtn")
?.addEventListener("click", () => ?.addEventListener("click", () => this.expandToFullChat());
this.expandToFullChat(),
);
// Global shortcut (Cmd+K / Ctrl+K) // Global shortcut (Cmd+K / Ctrl+K)
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
@ -159,17 +147,11 @@ const Omnibox = {
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
this.selectedIndex = Math.min( this.selectedIndex = Math.min(this.selectedIndex + 1, actions.length - 1);
this.selectedIndex + 1,
actions.length - 1,
);
this.updateSelection(actions); this.updateSelection(actions);
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
this.selectedIndex = Math.max( this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.selectedIndex - 1,
0,
);
this.updateSelection(actions); this.updateSelection(actions);
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@ -185,17 +167,13 @@ const Omnibox = {
updateSelection(actions) { updateSelection(actions) {
actions.forEach((a, i) => { actions.forEach((a, i) => {
a.classList.toggle( a.classList.toggle("selected", i === this.selectedIndex);
"selected",
i === this.selectedIndex,
);
}); });
}, },
updateActions() { updateActions() {
const currentApp = this.getCurrentApp(); const currentApp = this.getCurrentApp();
const actionsContainer = const actionsContainer = document.getElementById("omniboxActions");
document.getElementById("omniboxActions");
const contextActions = { const contextActions = {
chat: [ chat: [
@ -362,12 +340,8 @@ const Omnibox = {
.join(""); .join("");
// Rebind events // Rebind events
actionsContainer actionsContainer.querySelectorAll(".omnibox-action").forEach((btn) => {
.querySelectorAll(".omnibox-action") btn.addEventListener("click", () => this.handleAction(btn));
.forEach((btn) => {
btn.addEventListener("click", () =>
this.handleAction(btn),
);
}); });
this.selectedIndex = 0; this.selectedIndex = 0;
@ -398,9 +372,7 @@ const Omnibox = {
navigateTo(target) { navigateTo(target) {
this.close(); this.close();
const link = document.querySelector( const link = document.querySelector(`a[data-section="${target}"]`);
`a[data-section="${target}"]`,
);
if (link) { if (link) {
link.click(); link.click();
} }
@ -436,32 +408,24 @@ const Omnibox = {
}, },
showDefaultActions() { showDefaultActions() {
document.getElementById( document.getElementById("searchResultsSection").style.display = "none";
"searchResultsSection",
).style.display = "none";
this.updateActions(); this.updateActions();
}, },
searchContent(query) { searchContent(query) {
// Show search results section // Show search results section
const resultsSection = document.getElementById( const resultsSection = document.getElementById("searchResultsSection");
"searchResultsSection", const resultsList = document.getElementById("searchResultsList");
);
const resultsList =
document.getElementById("searchResultsList");
resultsSection.style.display = "block"; resultsSection.style.display = "block";
// Update first action to be "Ask about: query" // Update first action to be "Ask about: query"
const actionsContainer = const actionsContainer = document.getElementById("omniboxActions");
document.getElementById("omniboxActions"); const firstAction = actionsContainer.querySelector(".omnibox-action");
const firstAction =
actionsContainer.querySelector(".omnibox-action");
if (firstAction) { if (firstAction) {
firstAction.dataset.action = "chat"; firstAction.dataset.action = "chat";
firstAction.dataset.query = query; firstAction.dataset.query = query;
firstAction.querySelector(".action-icon").textContent = firstAction.querySelector(".action-icon").textContent = "💬";
"💬";
firstAction.querySelector(".action-text").textContent = firstAction.querySelector(".action-text").textContent =
`Ask: "${query.substring(0, 30)}${query.length > 30 ? "..." : ""}"`; `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>'; '<div class="no-results">No results found. Try asking the bot!</div>';
// Bind click events // Bind click events
resultsList resultsList.querySelectorAll(".omnibox-result").forEach((btn) => {
.querySelectorAll(".omnibox-result") btn.addEventListener("click", () => this.navigateTo(btn.dataset.target));
.forEach((btn) => {
btn.addEventListener("click", () =>
this.navigateTo(btn.dataset.target),
);
}); });
}, },
@ -581,17 +541,16 @@ const Omnibox = {
title: "Settings", title: "Settings",
description: "App settings", description: "App settings",
}, },
]; ];
// Add plugin apps // Add plugin apps
const pluginApps = window.Suite.getAllApps() const pluginApps = window.Suite.getAllApps()
.filter(app => app.searchable) .filter((app) => app.searchable)
.map(app => ({ .map((app) => ({
target: app.id, target: app.id,
icon: app.icon || "📦", icon: app.icon || "📦",
title: app.title || app.id, title: app.title || app.id,
description: app.description || "App plugin" description: app.description || "App plugin",
})); }));
const allItems = items.concat(pluginApps); const allItems = items.concat(pluginApps);
@ -641,9 +600,7 @@ const Omnibox = {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
this.addMessage( this.addMessage(
data.reply || data.reply || data.message || "I received your message.",
data.message ||
"I received your message.",
"bot", "bot",
); );
@ -660,10 +617,7 @@ const Omnibox = {
} catch (error) { } catch (error) {
this.removeTypingIndicator(typingId); this.removeTypingIndicator(typingId);
// Fallback response when API is not available // Fallback response when API is not available
this.addMessage( this.addMessage(this.getFallbackResponse(message), "bot");
this.getFallbackResponse(message),
"bot",
);
} }
}, },
@ -697,8 +651,7 @@ const Omnibox = {
<div class="message-content">${this.escapeHtml(text)}</div> <div class="message-content">${this.escapeHtml(text)}</div>
`; `;
this.chatMessages.appendChild(msgDiv); this.chatMessages.appendChild(msgDiv);
this.chatMessages.scrollTop = this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
this.chatMessages.scrollHeight;
this.chatHistory.push({ role: sender, content: text }); this.chatHistory.push({ role: sender, content: text });
}, },
@ -715,8 +668,7 @@ const Omnibox = {
</div> </div>
`; `;
this.chatMessages.appendChild(typingDiv); this.chatMessages.appendChild(typingDiv);
this.chatMessages.scrollTop = this.chatMessages.scrollTop = this.chatMessages.scrollHeight;
this.chatMessages.scrollHeight;
return id; return id;
}, },
@ -727,18 +679,13 @@ const Omnibox = {
handleBotAction(action) { handleBotAction(action) {
if (action.navigate) { if (action.navigate) {
setTimeout( setTimeout(() => this.navigateTo(action.navigate), 1000);
() => this.navigateTo(action.navigate),
1000,
);
} }
}, },
expandToFullChat() { expandToFullChat() {
this.close(); this.close();
const chatLink = document.querySelector( const chatLink = document.querySelector('a[data-section="chat"]');
'a[data-section="chat"]',
);
if (chatLink) chatLink.click(); if (chatLink) chatLink.click();
}, },
@ -751,13 +698,57 @@ const Omnibox = {
// Initialize Omnibox when DOM is ready // Initialize Omnibox when DOM is ready
document.addEventListener("DOMContentLoaded", () => { 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(); Omnibox.init();
console.log("🚀 Initializing General Bots with HTMX..."); console.log("🚀 Initializing General Bots with HTMX...");
// Check bot public status early
checkBotPublicStatus();
// Hide loading overlay // Hide loading overlay
setTimeout(() => { setTimeout(() => {
const loadingOverlay = const loadingOverlay = document.getElementById("loadingOverlay");
document.getElementById("loadingOverlay");
if (loadingOverlay) { if (loadingOverlay) {
loadingOverlay.classList.add("hidden"); loadingOverlay.classList.add("hidden");
} }
@ -775,15 +766,11 @@ document.addEventListener("DOMContentLoaded", () => {
const isOpen = appsDropdown.classList.toggle("show"); const isOpen = appsDropdown.classList.toggle("show");
appsBtn.setAttribute("aria-expanded", isOpen); appsBtn.setAttribute("aria-expanded", isOpen);
// Close settings panel // Close settings panel
if (settingsPanel) if (settingsPanel) settingsPanel.classList.remove("show");
settingsPanel.classList.remove("show");
}); });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if ( if (!appsDropdown.contains(e.target) && !appsBtn.contains(e.target)) {
!appsDropdown.contains(e.target) &&
!appsBtn.contains(e.target)
) {
appsDropdown.classList.remove("show"); appsDropdown.classList.remove("show");
appsBtn.setAttribute("aria-expanded", "false"); appsBtn.setAttribute("aria-expanded", "false");
} }
@ -813,8 +800,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Theme selection handling // Theme selection handling
const themeOptions = document.querySelectorAll(".theme-option"); const themeOptions = document.querySelectorAll(".theme-option");
const savedTheme = const savedTheme = localStorage.getItem("gb-theme") || "sentient";
localStorage.getItem("gb-theme") || "sentient";
// Apply saved theme // Apply saved theme
document.body.setAttribute("data-theme", savedTheme); document.body.setAttribute("data-theme", savedTheme);
@ -827,9 +813,7 @@ document.addEventListener("DOMContentLoaded", () => {
const theme = option.getAttribute("data-theme"); const theme = option.getAttribute("data-theme");
document.body.setAttribute("data-theme", theme); document.body.setAttribute("data-theme", theme);
localStorage.setItem("gb-theme", theme); localStorage.setItem("gb-theme", theme);
themeOptions.forEach((o) => themeOptions.forEach((o) => o.classList.remove("active"));
o.classList.remove("active"),
);
option.classList.add("active"); option.classList.add("active");
// Update theme-color meta tag // Update theme-color meta tag
@ -841,14 +825,9 @@ document.addEventListener("DOMContentLoaded", () => {
orange: "#f97316", orange: "#f97316",
sentient: "#d4f505", sentient: "#d4f505",
}; };
const metaTheme = document.querySelector( const metaTheme = document.querySelector('meta[name="theme-color"]');
'meta[name="theme-color"]',
);
if (metaTheme) { if (metaTheme) {
metaTheme.setAttribute( metaTheme.setAttribute("content", themeColors[theme] || "#d4f505");
"content",
themeColors[theme] || "#d4f505",
);
} }
}); });
}); });
@ -959,17 +938,13 @@ document.addEventListener("DOMContentLoaded", () => {
// Validate target exists before triggering HTMX load // Validate target exists before triggering HTMX load
if (!mainContent) { if (!mainContent) {
console.warn( console.warn("handleHashChange: #main-content not found, skipping load");
"handleHashChange: #main-content not found, skipping load",
);
return; return;
} }
// Check if main-content is in the DOM // Check if main-content is in the DOM
if (!document.body.contains(mainContent)) { if (!document.body.contains(mainContent)) {
console.warn( console.warn("handleHashChange: #main-content not in DOM, skipping load");
"handleHashChange: #main-content not in DOM, skipping load",
);
return; return;
} }
@ -984,8 +959,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Debounce the load to prevent rapid double-requests // Debounce the load to prevent rapid double-requests
pendingLoadTimeout = setTimeout(() => { pendingLoadTimeout = setTimeout(() => {
// Re-check if section changed during debounce // Re-check if section changed during debounce
const currentHash = const currentHash = window.location.hash.slice(1) || "chat";
window.location.hash.slice(1) || "chat";
if (currentLoadedSection === currentHash) { if (currentLoadedSection === currentHash) {
return; return;
} }
@ -1005,10 +979,7 @@ document.addEventListener("DOMContentLoaded", () => {
swap: "innerHTML", swap: "innerHTML",
}); });
} catch (e) { } catch (e) {
console.warn( console.warn("handleHashChange: HTMX ajax error:", e);
"handleHashChange: HTMX ajax error:",
e,
);
currentLoadedSection = null; currentLoadedSection = null;
isLoadingSection = false; isLoadingSection = false;
} }
@ -1019,10 +990,7 @@ document.addEventListener("DOMContentLoaded", () => {
// Listen for HTMX swaps to track loaded sections and prevent duplicates // Listen for HTMX swaps to track loaded sections and prevent duplicates
document.body.addEventListener("htmx:afterSwap", (event) => { document.body.addEventListener("htmx:afterSwap", (event) => {
if ( if (event.detail.target && event.detail.target.id === "main-content") {
event.detail.target &&
event.detail.target.id === "main-content"
) {
const hash = window.location.hash.slice(1) || "chat"; const hash = window.location.hash.slice(1) || "chat";
currentLoadedSection = hash; currentLoadedSection = hash;
isLoadingSection = false; isLoadingSection = false;
@ -1031,27 +999,18 @@ document.addEventListener("DOMContentLoaded", () => {
// Reset tracking on swap errors // Reset tracking on swap errors
document.body.addEventListener("htmx:swapError", (event) => { document.body.addEventListener("htmx:swapError", (event) => {
if ( if (event.detail.target && event.detail.target.id === "main-content") {
event.detail.target &&
event.detail.target.id === "main-content"
) {
isLoadingSection = false; isLoadingSection = false;
} }
}); });
// Also listen for response errors // Also listen for response errors
document.body.addEventListener( document.body.addEventListener("htmx:responseError", (event) => {
"htmx:responseError", if (event.detail.target && event.detail.target.id === "main-content") {
(event) => {
if (
event.detail.target &&
event.detail.target.id === "main-content"
) {
isLoadingSection = false; isLoadingSection = false;
currentLoadedSection = null; currentLoadedSection = null;
} }
}, });
);
// Load initial content based on hash or default to chat // Load initial content based on hash or default to chat
window.addEventListener("hashchange", handleHashChange); window.addEventListener("hashchange", handleHashChange);
@ -1082,17 +1041,13 @@ document.addEventListener("DOMContentLoaded", () => {
const list = document.getElementById("notificationsList"); const list = document.getElementById("notificationsList");
const btn = document.getElementById("notificationsBtn"); const btn = document.getElementById("notificationsBtn");
const panel = document.getElementById("notificationsPanel"); const panel = document.getElementById("notificationsPanel");
const clearBtn = document.getElementById( const clearBtn = document.getElementById("clearNotificationsBtn");
"clearNotificationsBtn",
);
function updateBadge() { function updateBadge() {
if (badge) { if (badge) {
if (notifications.length > 0) { if (notifications.length > 0) {
badge.textContent = badge.textContent =
notifications.length > 99 notifications.length > 99 ? "99+" : notifications.length;
? "99+"
: notifications.length;
badge.style.display = "flex"; badge.style.display = "flex";
} else { } else {
badge.style.display = "none"; badge.style.display = "none";
@ -1172,10 +1127,7 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if ( if (!panel.contains(e.target) && !btn.contains(e.target)) {
!panel.contains(e.target) &&
!btn.contains(e.target)
) {
panel.classList.remove("show"); panel.classList.remove("show");
btn.setAttribute("aria-expanded", "false"); btn.setAttribute("aria-expanded", "false");
} }
@ -1241,9 +1193,7 @@ document.addEventListener("DOMContentLoaded", () => {
? "🔴" ? "🔴"
: "🟡", : "🟡",
title: title:
"Connection " + "Connection " + status.charAt(0).toUpperCase() + status.slice(1),
status.charAt(0).toUpperCase() +
status.slice(1),
message: message || "", message: message || "",
}); });
}, },
@ -1256,8 +1206,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (e.altKey && !e.ctrlKey && !e.shiftKey) { if (e.altKey && !e.ctrlKey && !e.shiftKey) {
const num = parseInt(e.key); const num = parseInt(e.key);
if (num >= 1 && num <= 9) { if (num >= 1 && num <= 9) {
const items = const items = document.querySelectorAll(".app-item");
document.querySelectorAll(".app-item");
if (items[num - 1]) { if (items[num - 1]) {
items[num - 1].click(); items[num - 1].click();
e.preventDefault(); e.preventDefault();
@ -1281,21 +1230,18 @@ document.addEventListener("DOMContentLoaded", () => {
const userName = document.getElementById("userName"); const userName = document.getElementById("userName");
const userEmail = document.getElementById("userEmail"); const userEmail = document.getElementById("userEmail");
const userAvatar = document.getElementById("userAvatar"); const userAvatar = document.getElementById("userAvatar");
const userAvatarLarge = const userAvatarLarge = document.getElementById("userAvatarLarge");
document.getElementById("userAvatarLarge");
const authAction = document.getElementById("authAction"); const authAction = document.getElementById("authAction");
const authText = document.getElementById("authText"); const authText = document.getElementById("authText");
const authIcon = document.getElementById("authIcon"); const authIcon = document.getElementById("authIcon");
const settingsBtn = document.getElementById("settingsBtn");
const appsButton = document.getElementById("appsButton");
const notificationsBtn = document.getElementById("notificationsBtn");
const displayName = const displayName =
user.display_name || user.display_name || user.first_name || user.username || "User";
user.first_name ||
user.username ||
"User";
const email = user.email || ""; const email = user.email || "";
const initial = ( const initial = (displayName.charAt(0) || "U").toUpperCase();
displayName.charAt(0) || "U"
).toUpperCase();
console.log("Updating user UI:", displayName, email); console.log("Updating user UI:", displayName, email);
@ -1328,14 +1274,20 @@ document.addEventListener("DOMContentLoaded", () => {
authIcon.innerHTML = 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>'; '<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() { function loadUserProfile() {
var token = var token =
localStorage.getItem("gb-access-token") || localStorage.getItem("gb-access-token") ||
sessionStorage.getItem("gb-access-token"); sessionStorage.getItem("gb-access-token");
if (!token) { if (!token) {
console.log("No auth token found"); console.log("No auth token found - user is signed out");
updateSignedOutUI();
return; return;
} }
@ -1348,22 +1300,60 @@ document.addEventListener("DOMContentLoaded", () => {
headers: { Authorization: "Bearer " + token }, headers: { Authorization: "Bearer " + token },
}) })
.then(function (res) { .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(); return res.json();
}) })
.then(function (user) { .then(function (user) {
console.log("User profile loaded:", user); console.log("User profile loaded:", user);
updateUserUI(user); updateUserUI(user);
localStorage.setItem( localStorage.setItem("gb-user-data", JSON.stringify(user));
"gb-user-data",
JSON.stringify(user),
);
}) })
.catch(function (err) { .catch(function (err) {
console.log("Failed to load user profile:", 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 // Try to load cached user first
var cachedUser = localStorage.getItem("gb-user-data"); var cachedUser = localStorage.getItem("gb-user-data");
if (cachedUser) { if (cachedUser) {
@ -1372,15 +1362,12 @@ document.addEventListener("DOMContentLoaded", () => {
if (user && user.email) { if (user && user.email) {
updateUserUI(user); updateUserUI(user);
} }
} catch (e) { } } catch (e) {}
} }
// Always fetch fresh user data // Always fetch fresh user data
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener( document.addEventListener("DOMContentLoaded", loadUserProfile);
"DOMContentLoaded",
loadUserProfile,
);
} else { } else {
loadUserProfile(); loadUserProfile();
} }

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"> <div class="autotask-container" data-theme="sentient">
<!-- Top Navigation Bar --> <!-- Top Navigation Bar -->
@ -478,6 +478,6 @@ Examples:
<!-- Toast Container --> <!-- Toast Container -->
<div class="toast-container" id="toast-container"></div> <div class="toast-container" id="toast-container"></div>
<link rel="stylesheet" href="tasks/progress-panel.css" /> <link rel="stylesheet" href="/suite/tasks/progress-panel.css" />
<script src="tasks/progress-panel.js"></script> <script src="/suite/tasks/progress-panel.js"></script>
<script src="tasks/autotask.js"></script> <script src="/suite/tasks/autotask.js"></script>

View file

@ -213,8 +213,10 @@ function setupIntentInputHandlers() {
} }
// Task polling for async task creation // Task polling for async task creation
let activePollingTaskId = null; if (typeof activePollingTaskId === "undefined") {
let pollingInterval = null; var activePollingTaskId = null;
var pollingInterval = null;
}
function startTaskPolling(taskId) { function startTaskPolling(taskId) {
// Stop any existing polling // Stop any existing polling
@ -629,7 +631,9 @@ function handleWebSocketMessage(data) {
} }
// Store pending manifest updates for tasks whose elements aren't loaded yet // 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( function renderManifestProgress(
taskId, taskId,
@ -2759,8 +2763,9 @@ function formatTime(seconds) {
// GLOBAL STYLES FOR TOAST ANIMATIONS // GLOBAL STYLES FOR TOAST ANIMATIONS
// ============================================================================= // =============================================================================
const style = document.createElement("style"); if (typeof taskStyleElement === "undefined") {
style.textContent = ` var taskStyleElement = document.createElement("style");
taskStyleElement.textContent = `
@keyframes slideIn { @keyframes slideIn {
from { from {
opacity: 0; opacity: 0;
@ -2805,7 +2810,8 @@ style.textContent = `
} }
} }
`; `;
document.head.appendChild(style); document.head.appendChild(taskStyleElement);
}
// ============================================================================= // =============================================================================
// GOALS, PENDING INFO, SCHEDULERS, MONITORS // GOALS, PENDING INFO, SCHEDULERS, MONITORS