Add public assets route and improve chat initialization
- Add /public static file route for themes and shared assets - Fix chat app initialization for dynamic script loading - Add ThemeManager with dropdown selector for 19 theme variants - Improve module lazy loading with better retry logic - Add loading overlay for smoother app startup - Refactor app.css to use unified theme variables - Format code and organize imports consistently
This commit is contained in:
parent
37a15ea9e0
commit
eb4084fe2d
8 changed files with 1599 additions and 212 deletions
|
|
@ -137,6 +137,7 @@ async fn run_axum_server(
|
||||||
// Static file services must come first to match before other routes
|
// Static file services must come first to match before other routes
|
||||||
.nest_service("/js", ServeDir::new(static_path.join("js")))
|
.nest_service("/js", ServeDir::new(static_path.join("js")))
|
||||||
.nest_service("/css", ServeDir::new(static_path.join("css")))
|
.nest_service("/css", ServeDir::new(static_path.join("css")))
|
||||||
|
.nest_service("/public", ServeDir::new(static_path.join("public")))
|
||||||
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
|
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
|
||||||
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
|
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
|
||||||
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
|
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
Router,
|
|
||||||
routing::get,
|
|
||||||
response::{Html, IntoResponse},
|
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
|
response::{Html, IntoResponse},
|
||||||
|
routing::get,
|
||||||
|
Router,
|
||||||
};
|
};
|
||||||
use tower_http::services::ServeDir;
|
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::{fs, path::PathBuf};
|
use std::{fs, path::PathBuf};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
pub async fn index() -> impl IntoResponse {
|
pub async fn index() -> impl IntoResponse {
|
||||||
match fs::read_to_string("web/desktop/index.html") {
|
match fs::read_to_string("web/desktop/index.html") {
|
||||||
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
|
Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Failed to load index page: {}", e);
|
error!("Failed to load index page: {}", e);
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, [("content-type", "text/plain")], Html("Failed to load index page".to_string()))
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
[("content-type", "text/plain")],
|
||||||
|
Html("Failed to load index page".to_string()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -26,14 +30,17 @@ pub fn configure_router() -> Router {
|
||||||
.nest_service("/js", ServeDir::new(static_path.join("js")))
|
.nest_service("/js", ServeDir::new(static_path.join("js")))
|
||||||
// Serve CSS files
|
// Serve CSS files
|
||||||
.nest_service("/css", ServeDir::new(static_path.join("css")))
|
.nest_service("/css", ServeDir::new(static_path.join("css")))
|
||||||
|
// Serve public assets (themes, etc.)
|
||||||
|
.nest_service("/public", ServeDir::new(static_path.join("public")))
|
||||||
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
|
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
|
||||||
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
|
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
|
||||||
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
|
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
|
||||||
.nest_service("/tasks", ServeDir::new(static_path.join("tasks")))
|
.nest_service("/tasks", ServeDir::new(static_path.join("tasks")))
|
||||||
// Fallback: serve static files and index.html for SPA routing
|
// Fallback: serve static files and index.html for SPA routing
|
||||||
.fallback_service(
|
.fallback_service(
|
||||||
ServeDir::new(static_path.clone())
|
ServeDir::new(static_path.clone()).fallback(
|
||||||
.fallback(ServeDir::new(static_path.clone()).append_index_html_on_directories(true))
|
ServeDir::new(static_path.clone()).append_index_html_on_directories(true),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.route("/", get(index))
|
.route("/", get(index))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ function chatApp() {
|
||||||
}
|
}
|
||||||
isInitialized = true;
|
isInitialized = true;
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
const initializeDOM = () => {
|
||||||
// Assign DOM elements after the document is ready
|
// Assign DOM elements after the document is ready
|
||||||
messagesDiv = document.getElementById("messages");
|
messagesDiv = document.getElementById("messages");
|
||||||
|
|
||||||
|
|
@ -241,7 +241,15 @@ function chatApp() {
|
||||||
|
|
||||||
// Initialize auth only once
|
// Initialize auth only once
|
||||||
this.initializeAuth();
|
this.initializeAuth();
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Check if DOM is already loaded (for dynamic script loading)
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
window.addEventListener("load", initializeDOM);
|
||||||
|
} else {
|
||||||
|
// DOM is already loaded, initialize immediately
|
||||||
|
initializeDOM();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
flashScreen() {
|
flashScreen() {
|
||||||
|
|
@ -971,8 +979,32 @@ function chatApp() {
|
||||||
return returnValue;
|
return returnValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the app
|
// Initialize the app - expose globally for dynamic loading
|
||||||
chatApp().init();
|
window.chatAppInstance = chatApp();
|
||||||
|
|
||||||
|
// Auto-initialize if we're already on the chat section
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
if (hash === "chat" || hash === "" || !hash) {
|
||||||
|
window.chatAppInstance.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If script is loaded dynamically, section-shown event will trigger init
|
||||||
|
const chatSection = document.getElementById("section-chat");
|
||||||
|
if (chatSection) {
|
||||||
|
window.chatAppInstance.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for section being shown
|
||||||
|
document.addEventListener("section-shown", function (e) {
|
||||||
|
if (e.target.id === "section-chat" && window.chatAppInstance) {
|
||||||
|
console.log("Chat section shown, initializing...");
|
||||||
|
window.chatAppInstance.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Listen for section changes to cleanup when leaving chat
|
// Listen for section changes to cleanup when leaving chat
|
||||||
document.addEventListener("section-hidden", function (e) {
|
document.addEventListener("section-hidden", function (e) {
|
||||||
|
|
|
||||||
986
web/desktop/chat/chat.js.backup
Normal file
986
web/desktop/chat/chat.js.backup
Normal file
|
|
@ -0,0 +1,986 @@
|
||||||
|
// Singleton instance to prevent multiple initializations
|
||||||
|
let chatAppInstance = null;
|
||||||
|
|
||||||
|
function chatApp() {
|
||||||
|
// Return existing instance if already created
|
||||||
|
if (chatAppInstance) {
|
||||||
|
console.log("Returning existing chatApp instance");
|
||||||
|
return chatAppInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Creating new chatApp instance");
|
||||||
|
|
||||||
|
// Core state variables (shared via closure)
|
||||||
|
let ws = null,
|
||||||
|
pendingContextChange = null,
|
||||||
|
o,
|
||||||
|
isConnecting = false,
|
||||||
|
isInitialized = false,
|
||||||
|
authPromise = null;
|
||||||
|
((currentSessionId = null),
|
||||||
|
(currentUserId = null),
|
||||||
|
(currentBotId = "default_bot"),
|
||||||
|
(isStreaming = false),
|
||||||
|
(voiceRoom = null),
|
||||||
|
(isVoiceMode = false),
|
||||||
|
(mediaRecorder = null),
|
||||||
|
(audioChunks = []),
|
||||||
|
(streamingMessageId = null),
|
||||||
|
(isThinking = false),
|
||||||
|
(currentStreamingContent = ""),
|
||||||
|
(hasReceivedInitialMessage = false),
|
||||||
|
(reconnectAttempts = 0),
|
||||||
|
(reconnectTimeout = null),
|
||||||
|
(thinkingTimeout = null),
|
||||||
|
(currentTheme = "auto"),
|
||||||
|
(themeColor1 = null),
|
||||||
|
(themeColor2 = null),
|
||||||
|
(customLogoUrl = null),
|
||||||
|
(contextUsage = 0),
|
||||||
|
(isUserScrolling = false),
|
||||||
|
(autoScrollEnabled = true),
|
||||||
|
(isContextChange = false));
|
||||||
|
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
|
||||||
|
// DOM references (cached for performance)
|
||||||
|
let messagesDiv,
|
||||||
|
messageInputEl,
|
||||||
|
sendBtn,
|
||||||
|
voiceBtn,
|
||||||
|
connectionStatus,
|
||||||
|
flashOverlay,
|
||||||
|
suggestionsContainer,
|
||||||
|
floatLogo,
|
||||||
|
sidebar,
|
||||||
|
themeBtn,
|
||||||
|
scrollToBottomBtn,
|
||||||
|
sidebarTitle;
|
||||||
|
|
||||||
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// UI state (mirrors the structure used in driveApp)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
current: "All Chats",
|
||||||
|
search: "",
|
||||||
|
selectedChat: null,
|
||||||
|
navItems: [
|
||||||
|
{ name: "All Chats", icon: "💬" },
|
||||||
|
{ name: "Direct", icon: "👤" },
|
||||||
|
{ name: "Groups", icon: "👥" },
|
||||||
|
{ name: "Archived", icon: "🗄" },
|
||||||
|
],
|
||||||
|
chats: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "General Bot Support",
|
||||||
|
icon: "🤖",
|
||||||
|
lastMessage: "How can I help you?",
|
||||||
|
time: "10:15 AM",
|
||||||
|
status: "Online",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Project Alpha",
|
||||||
|
icon: "🚀",
|
||||||
|
lastMessage: "Launch scheduled for tomorrow.",
|
||||||
|
time: "Yesterday",
|
||||||
|
status: "Active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Team Stand‑up",
|
||||||
|
icon: "🗣️",
|
||||||
|
lastMessage: "Done with the UI updates.",
|
||||||
|
time: "2 hrs ago",
|
||||||
|
status: "Active",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Random Chat",
|
||||||
|
icon: "🎲",
|
||||||
|
lastMessage: "Did you see the game last night?",
|
||||||
|
time: "5 hrs ago",
|
||||||
|
status: "Idle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: "Support Ticket #1234",
|
||||||
|
icon: "🛠️",
|
||||||
|
lastMessage: "Issue resolved, closing ticket.",
|
||||||
|
time: "3 days ago",
|
||||||
|
status: "Closed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
get filteredChats() {
|
||||||
|
return this.chats.filter((chat) =>
|
||||||
|
chat.name.toLowerCase().includes(this.search.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// UI helpers (formerly standalone functions)
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
toggleSidebar() {
|
||||||
|
sidebar.classList.toggle("open");
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
const themes = ["auto", "dark", "light"];
|
||||||
|
const savedTheme = localStorage.getItem("gb-theme") || "auto";
|
||||||
|
const idx = themes.indexOf(savedTheme);
|
||||||
|
const newTheme = themes[(idx + 1) % themes.length];
|
||||||
|
localStorage.setItem("gb-theme", newTheme);
|
||||||
|
currentTheme = newTheme;
|
||||||
|
this.applyTheme();
|
||||||
|
this.updateThemeButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyTheme() {
|
||||||
|
const prefersDark = window.matchMedia(
|
||||||
|
"(prefers-color-scheme: dark)",
|
||||||
|
).matches;
|
||||||
|
let theme = currentTheme;
|
||||||
|
if (theme === "auto") {
|
||||||
|
theme = prefersDark ? "dark" : "light";
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
if (themeColor1 && themeColor2) {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.style.setProperty(
|
||||||
|
"--bg",
|
||||||
|
theme === "dark" ? themeColor2 : themeColor1,
|
||||||
|
);
|
||||||
|
root.style.setProperty(
|
||||||
|
"--fg",
|
||||||
|
theme === "dark" ? themeColor1 : themeColor2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (customLogoUrl) {
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
"--logo-url",
|
||||||
|
`url('${customLogoUrl}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// Lifecycle / event handlers
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
init() {
|
||||||
|
// Prevent multiple initializations
|
||||||
|
if (isInitialized) {
|
||||||
|
console.log("Already initialized, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isInitialized = true;
|
||||||
|
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
// Assign DOM elements after the document is ready
|
||||||
|
messagesDiv = document.getElementById("messages");
|
||||||
|
|
||||||
|
messageInputEl = document.getElementById("messageInput");
|
||||||
|
sendBtn = document.getElementById("sendBtn");
|
||||||
|
voiceBtn = document.getElementById("voiceBtn");
|
||||||
|
connectionStatus = document.getElementById("connectionStatus");
|
||||||
|
flashOverlay = document.getElementById("flashOverlay");
|
||||||
|
suggestionsContainer = document.getElementById("suggestions");
|
||||||
|
floatLogo = document.getElementById("floatLogo");
|
||||||
|
sidebar = document.getElementById("sidebar");
|
||||||
|
themeBtn = document.getElementById("themeBtn");
|
||||||
|
scrollToBottomBtn = document.getElementById("scrollToBottom");
|
||||||
|
sidebarTitle = document.getElementById("sidebarTitle");
|
||||||
|
|
||||||
|
// Theme initialization and focus
|
||||||
|
const savedTheme = localStorage.getItem("gb-theme") || "auto";
|
||||||
|
currentTheme = savedTheme;
|
||||||
|
this.applyTheme();
|
||||||
|
window
|
||||||
|
.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.addEventListener("change", () => {
|
||||||
|
if (currentTheme === "auto") {
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (messageInputEl) {
|
||||||
|
messageInputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI event listeners
|
||||||
|
document.addEventListener("click", (e) => {});
|
||||||
|
|
||||||
|
// Scroll detection
|
||||||
|
if (messagesDiv && scrollToBottomBtn) {
|
||||||
|
messagesDiv.addEventListener("scroll", () => {
|
||||||
|
const isAtBottom =
|
||||||
|
messagesDiv.scrollHeight - messagesDiv.scrollTop <=
|
||||||
|
messagesDiv.clientHeight + 100;
|
||||||
|
if (!isAtBottom) {
|
||||||
|
isUserScrolling = true;
|
||||||
|
scrollToBottomBtn.classList.add("visible");
|
||||||
|
} else {
|
||||||
|
isUserScrolling = false;
|
||||||
|
scrollToBottomBtn.classList.remove("visible");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollToBottomBtn.addEventListener("click", () => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.onclick = () => this.sendMessage();
|
||||||
|
messageInputEl.addEventListener("keypress", (e) => {
|
||||||
|
if (e.key === "Enter") this.sendMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't auto-reconnect on focus in browser to prevent multiple connections
|
||||||
|
// Tauri doesn't fire focus events the same way
|
||||||
|
|
||||||
|
// Initialize auth only once
|
||||||
|
this.initializeAuth();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
flashScreen() {
|
||||||
|
gsap.to(flashOverlay, {
|
||||||
|
opacity: 0.15,
|
||||||
|
duration: 0.1,
|
||||||
|
onComplete: () => {
|
||||||
|
gsap.to(flashOverlay, { opacity: 0, duration: 0.2 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConnectionStatus(s) {
|
||||||
|
connectionStatus.className = `connection-status ${s}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getWebSocketUrl() {
|
||||||
|
const p = "ws:",
|
||||||
|
s = currentSessionId || crypto.randomUUID(),
|
||||||
|
u = currentUserId || crypto.randomUUID();
|
||||||
|
return `${p}//localhost:8080/ws?session_id=${s}&user_id=${u}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async initializeAuth() {
|
||||||
|
// Return existing promise if auth is in progress
|
||||||
|
if (authPromise) {
|
||||||
|
console.log("Auth already in progress, waiting...");
|
||||||
|
return authPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already authenticated
|
||||||
|
if (
|
||||||
|
currentSessionId &&
|
||||||
|
currentUserId &&
|
||||||
|
ws &&
|
||||||
|
ws.readyState === WebSocket.OPEN
|
||||||
|
) {
|
||||||
|
console.log("Already authenticated and connected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create auth promise to prevent concurrent calls
|
||||||
|
authPromise = (async () => {
|
||||||
|
try {
|
||||||
|
this.updateConnectionStatus("connecting");
|
||||||
|
const p = window.location.pathname.split("/").filter((s) => s);
|
||||||
|
const b = p.length > 0 ? p[0] : "default";
|
||||||
|
const r = await fetch(
|
||||||
|
`http://localhost:8080/api/auth?bot_name=${encodeURIComponent(b)}`,
|
||||||
|
);
|
||||||
|
const a = await r.json();
|
||||||
|
currentUserId = a.user_id;
|
||||||
|
currentSessionId = a.session_id;
|
||||||
|
console.log("Auth successful:", { currentUserId, currentSessionId });
|
||||||
|
this.connectWebSocket();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to initialize auth:", e);
|
||||||
|
this.updateConnectionStatus("disconnected");
|
||||||
|
authPromise = null;
|
||||||
|
setTimeout(() => this.initializeAuth(), 3000);
|
||||||
|
} finally {
|
||||||
|
authPromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return authPromise;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadSessions() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://localhost:8080/api/sessions");
|
||||||
|
const s = await r.json();
|
||||||
|
const h = document.getElementById("history");
|
||||||
|
h.innerHTML = "";
|
||||||
|
s.forEach((session) => {
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "history-item";
|
||||||
|
item.textContent =
|
||||||
|
session.title || `Session ${session.session_id.substring(0, 8)}`;
|
||||||
|
item.onclick = () => this.switchSession(session.session_id);
|
||||||
|
h.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load sessions:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async createNewSession() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://localhost:8080/api/sessions", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const s = await r.json();
|
||||||
|
currentSessionId = s.session_id;
|
||||||
|
hasReceivedInitialMessage = false;
|
||||||
|
this.connectWebSocket();
|
||||||
|
this.loadSessions();
|
||||||
|
messagesDiv.innerHTML = "";
|
||||||
|
this.clearSuggestions();
|
||||||
|
if (isVoiceMode) {
|
||||||
|
await this.stopVoiceSession();
|
||||||
|
isVoiceMode = false;
|
||||||
|
const v = document.getElementById("voiceToggle");
|
||||||
|
v.textContent = "🎤 Voice Mode";
|
||||||
|
voiceBtn.classList.remove("recording");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to create session:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
switchSession(s) {
|
||||||
|
currentSessionId = s;
|
||||||
|
hasReceivedInitialMessage = false;
|
||||||
|
this.connectWebSocket();
|
||||||
|
if (isVoiceMode) {
|
||||||
|
this.startVoiceSession();
|
||||||
|
}
|
||||||
|
sidebar.classList.remove("open");
|
||||||
|
},
|
||||||
|
|
||||||
|
connectWebSocket() {
|
||||||
|
// Prevent multiple simultaneous connection attempts
|
||||||
|
if (isConnecting) {
|
||||||
|
console.log("Already connecting to WebSocket, skipping...");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ws &&
|
||||||
|
(ws.readyState === WebSocket.OPEN ||
|
||||||
|
ws.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
console.log("WebSocket already connected or connecting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws && ws.readyState !== WebSocket.CLOSED) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
clearTimeout(reconnectTimeout);
|
||||||
|
isConnecting = true;
|
||||||
|
|
||||||
|
const u = this.getWebSocketUrl();
|
||||||
|
console.log("Connecting to WebSocket:", u);
|
||||||
|
ws = new WebSocket(u);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const r = JSON.parse(e.data);
|
||||||
|
|
||||||
|
// Filter out welcome/connection messages that aren't BotResponse
|
||||||
|
if (r.type === "connected" || !r.message_type) {
|
||||||
|
console.log("Ignoring non-message:", r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.bot_id) {
|
||||||
|
currentBotId = r.bot_id;
|
||||||
|
}
|
||||||
|
// Message type 2 is a bot response (not an event)
|
||||||
|
// Message type 5 is context change
|
||||||
|
if (r.message_type === 5) {
|
||||||
|
isContextChange = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check if this is a special event message (has event field)
|
||||||
|
if (r.event) {
|
||||||
|
this.handleEvent(r.event, r.data || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.processMessageContent(r);
|
||||||
|
};
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.log("Connected to WebSocket");
|
||||||
|
isConnecting = false;
|
||||||
|
this.updateConnectionStatus("connected");
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
hasReceivedInitialMessage = false;
|
||||||
|
};
|
||||||
|
ws.onclose = (e) => {
|
||||||
|
console.log("WebSocket disconnected:", e.code, e.reason);
|
||||||
|
isConnecting = false;
|
||||||
|
this.updateConnectionStatus("disconnected");
|
||||||
|
if (isStreaming) {
|
||||||
|
this.showContinueButton();
|
||||||
|
}
|
||||||
|
if (reconnectAttempts < maxReconnectAttempts) {
|
||||||
|
reconnectAttempts++;
|
||||||
|
const d = Math.min(1000 * reconnectAttempts, 10000);
|
||||||
|
reconnectTimeout = setTimeout(() => {
|
||||||
|
this.updateConnectionStatus("connecting");
|
||||||
|
this.connectWebSocket();
|
||||||
|
}, d);
|
||||||
|
} else {
|
||||||
|
this.updateConnectionStatus("disconnected");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
console.error("WebSocket error:", e);
|
||||||
|
isConnecting = false;
|
||||||
|
this.updateConnectionStatus("disconnected");
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
processMessageContent(r) {
|
||||||
|
if (isContextChange) {
|
||||||
|
isContextChange = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore messages without content
|
||||||
|
if (!r.content && r.is_complete !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.suggestions && r.suggestions.length > 0) {
|
||||||
|
this.handleSuggestions(r.suggestions);
|
||||||
|
}
|
||||||
|
if (r.is_complete) {
|
||||||
|
if (isStreaming) {
|
||||||
|
this.finalizeStreamingMessage();
|
||||||
|
isStreaming = false;
|
||||||
|
streamingMessageId = null;
|
||||||
|
currentStreamingContent = "";
|
||||||
|
} else if (r.content) {
|
||||||
|
// Only add message if there's actual content
|
||||||
|
this.addMessage("assistant", r.content, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!isStreaming) {
|
||||||
|
isStreaming = true;
|
||||||
|
streamingMessageId = "streaming-" + Date.now();
|
||||||
|
currentStreamingContent = r.content || "";
|
||||||
|
this.addMessage(
|
||||||
|
"assistant",
|
||||||
|
currentStreamingContent,
|
||||||
|
true,
|
||||||
|
streamingMessageId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
currentStreamingContent += r.content || "";
|
||||||
|
this.updateStreamingMessage(currentStreamingContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleEvent(t, d) {
|
||||||
|
console.log("Event received:", t, d);
|
||||||
|
switch (t) {
|
||||||
|
case "thinking_start":
|
||||||
|
this.showThinkingIndicator();
|
||||||
|
break;
|
||||||
|
case "thinking_end":
|
||||||
|
this.hideThinkingIndicator();
|
||||||
|
break;
|
||||||
|
case "warn":
|
||||||
|
this.showWarning(d.message);
|
||||||
|
break;
|
||||||
|
case "context_usage":
|
||||||
|
// Context usage removed
|
||||||
|
break;
|
||||||
|
case "change_theme":
|
||||||
|
if (d.color1) themeColor1 = d.color1;
|
||||||
|
if (d.color2) themeColor2 = d.color2;
|
||||||
|
if (d.logo_url) customLogoUrl = d.logo_url;
|
||||||
|
if (d.title) document.title = d.title;
|
||||||
|
if (d.logo_text) {
|
||||||
|
sidebarTitle.textContent = d.logo_text;
|
||||||
|
}
|
||||||
|
this.applyTheme();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showThinkingIndicator() {
|
||||||
|
if (isThinking) return;
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.id = "thinking-indicator";
|
||||||
|
t.className = "message-container";
|
||||||
|
t.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="thinking-indicator"><div class="typing-dots"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div></div></div>`;
|
||||||
|
messagesDiv.appendChild(t);
|
||||||
|
gsap.to(t, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" });
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
thinkingTimeout = setTimeout(() => {
|
||||||
|
if (isThinking) {
|
||||||
|
this.hideThinkingIndicator();
|
||||||
|
this.showWarning(
|
||||||
|
"O servidor pode estar ocupado. A resposta está demorando demais.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
isThinking = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
hideThinkingIndicator() {
|
||||||
|
if (!isThinking) return;
|
||||||
|
const t = document.getElementById("thinking-indicator");
|
||||||
|
if (t) {
|
||||||
|
gsap.to(t, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.2,
|
||||||
|
onComplete: () => {
|
||||||
|
if (t.parentNode) {
|
||||||
|
t.remove();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (thinkingTimeout) {
|
||||||
|
clearTimeout(thinkingTimeout);
|
||||||
|
thinkingTimeout = null;
|
||||||
|
}
|
||||||
|
isThinking = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
showWarning(m) {
|
||||||
|
const w = document.createElement("div");
|
||||||
|
w.className = "warning-message";
|
||||||
|
w.innerHTML = `⚠️ ${m}`;
|
||||||
|
messagesDiv.appendChild(w);
|
||||||
|
gsap.from(w, { opacity: 0, y: 20, duration: 0.4, ease: "power2.out" });
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
if (w.parentNode) {
|
||||||
|
gsap.to(w, {
|
||||||
|
opacity: 0,
|
||||||
|
duration: 0.3,
|
||||||
|
onComplete: () => w.remove(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
showContinueButton() {
|
||||||
|
const c = document.createElement("div");
|
||||||
|
c.className = "message-container";
|
||||||
|
c.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content"><p>A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.</p><button class="continue-button" onclick="this.parentElement.parentElement.parentElement.remove();">Continuar</button></div></div>`;
|
||||||
|
messagesDiv.appendChild(c);
|
||||||
|
gsap.to(c, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" });
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
continueInterruptedResponse() {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
const d = {
|
||||||
|
bot_id: "default_bot",
|
||||||
|
user_id: currentUserId,
|
||||||
|
session_id: currentSessionId,
|
||||||
|
channel: "web",
|
||||||
|
content: "continue",
|
||||||
|
message_type: 3,
|
||||||
|
media_url: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(d));
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".continue-button").forEach((b) => {
|
||||||
|
b.parentElement.parentElement.parentElement.remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addMessage(role, content, streaming = false, msgId = null) {
|
||||||
|
const m = document.createElement("div");
|
||||||
|
m.className = "message-container";
|
||||||
|
if (role === "user") {
|
||||||
|
m.innerHTML = `<div class="user-message"><div class="user-message-content">${this.escapeHtml(content)}</div></div>`;
|
||||||
|
} else if (role === "assistant") {
|
||||||
|
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content markdown-content" id="${msgId || ""}">${streaming ? "" : marked.parse(content)}</div></div>`;
|
||||||
|
} else if (role === "voice") {
|
||||||
|
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar">🎤</div><div class="assistant-message-content">${content}</div></div>`;
|
||||||
|
} else {
|
||||||
|
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content">${content}</div></div>`;
|
||||||
|
}
|
||||||
|
messagesDiv.appendChild(m);
|
||||||
|
gsap.to(m, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" });
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStreamingMessage(c) {
|
||||||
|
const m = document.getElementById(streamingMessageId);
|
||||||
|
if (m) {
|
||||||
|
m.innerHTML = marked.parse(c);
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
finalizeStreamingMessage() {
|
||||||
|
const m = document.getElementById(streamingMessageId);
|
||||||
|
if (m) {
|
||||||
|
m.innerHTML = marked.parse(currentStreamingContent);
|
||||||
|
m.removeAttribute("id");
|
||||||
|
if (!isUserScrolling) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(t) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.textContent = t;
|
||||||
|
return d.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSuggestions() {
|
||||||
|
suggestionsContainer.innerHTML = "";
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSuggestions(s) {
|
||||||
|
const uniqueSuggestions = s.filter(
|
||||||
|
(v, i, a) =>
|
||||||
|
i ===
|
||||||
|
a.findIndex((t) => t.text === v.text && t.context === v.context),
|
||||||
|
);
|
||||||
|
suggestionsContainer.innerHTML = "";
|
||||||
|
uniqueSuggestions.forEach((v) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.textContent = v.text;
|
||||||
|
b.className = "suggestion-button";
|
||||||
|
b.onclick = () => {
|
||||||
|
this.setContext(v.context);
|
||||||
|
messageInputEl.value = "";
|
||||||
|
};
|
||||||
|
suggestionsContainer.appendChild(b);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async setContext(c) {
|
||||||
|
try {
|
||||||
|
const t = event?.target?.textContent || c;
|
||||||
|
this.addMessage("user", t);
|
||||||
|
messageInputEl.value = "";
|
||||||
|
messageInputEl.value = "";
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
pendingContextChange = new Promise((r) => {
|
||||||
|
const h = (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (d.message_type === 5 && d.context_name === c) {
|
||||||
|
ws.removeEventListener("message", h);
|
||||||
|
r();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.addEventListener("message", h);
|
||||||
|
const s = {
|
||||||
|
bot_id: currentBotId,
|
||||||
|
user_id: currentUserId,
|
||||||
|
session_id: currentSessionId,
|
||||||
|
channel: "web",
|
||||||
|
content: t,
|
||||||
|
message_type: 4,
|
||||||
|
is_suggestion: true,
|
||||||
|
context_name: c,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(s));
|
||||||
|
});
|
||||||
|
await pendingContextChange;
|
||||||
|
} else {
|
||||||
|
console.warn("WebSocket não está conectado. Tentando reconectar...");
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to set context:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
if (pendingContextChange) {
|
||||||
|
await pendingContextChange;
|
||||||
|
pendingContextChange = null;
|
||||||
|
}
|
||||||
|
const m = messageInputEl.value.trim();
|
||||||
|
if (!m || !ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
this.showWarning("Conexão não disponível. Tentando reconectar...");
|
||||||
|
this.connectWebSocket();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isThinking) {
|
||||||
|
this.hideThinkingIndicator();
|
||||||
|
}
|
||||||
|
this.addMessage("user", m);
|
||||||
|
const d = {
|
||||||
|
bot_id: currentBotId,
|
||||||
|
user_id: currentUserId,
|
||||||
|
session_id: currentSessionId,
|
||||||
|
channel: "web",
|
||||||
|
content: m,
|
||||||
|
message_type: 1,
|
||||||
|
media_url: null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(d));
|
||||||
|
messageInputEl.value = "";
|
||||||
|
messageInputEl.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleVoiceMode() {
|
||||||
|
isVoiceMode = !isVoiceMode;
|
||||||
|
const v = document.getElementById("voiceToggle");
|
||||||
|
if (isVoiceMode) {
|
||||||
|
v.textContent = "🔴 Stop Voice";
|
||||||
|
v.classList.add("recording");
|
||||||
|
await this.startVoiceSession();
|
||||||
|
} else {
|
||||||
|
v.textContent = "🎤 Voice Mode";
|
||||||
|
v.classList.remove("recording");
|
||||||
|
await this.stopVoiceSession();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async startVoiceSession() {
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://localhost:8080/api/voice/start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: currentSessionId,
|
||||||
|
user_id: currentUserId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.token) {
|
||||||
|
await this.connectToVoiceRoom(d.token);
|
||||||
|
this.startVoiceRecording();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start voice session:", e);
|
||||||
|
this.showWarning("Falha ao iniciar modo de voz");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopVoiceSession() {
|
||||||
|
if (!currentSessionId) return;
|
||||||
|
try {
|
||||||
|
await fetch("http://localhost:8080/api/voice/stop", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: currentSessionId }),
|
||||||
|
});
|
||||||
|
if (voiceRoom) {
|
||||||
|
voiceRoom.disconnect();
|
||||||
|
voiceRoom = null;
|
||||||
|
}
|
||||||
|
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to stop voice session:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async connectToVoiceRoom(t) {
|
||||||
|
try {
|
||||||
|
const r = new LiveKitClient.Room();
|
||||||
|
const p = "ws:",
|
||||||
|
u = `${p}//localhost:8080/voice`;
|
||||||
|
await r.connect(u, t);
|
||||||
|
voiceRoom = r;
|
||||||
|
r.on("dataReceived", (d) => {
|
||||||
|
const dc = new TextDecoder(),
|
||||||
|
m = dc.decode(d);
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(m);
|
||||||
|
if (j.type === "voice_response") {
|
||||||
|
this.addMessage("assistant", j.text);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Voice data:", m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const l = await LiveKitClient.createLocalTracks({
|
||||||
|
audio: true,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
for (const k of l) {
|
||||||
|
await r.localParticipant.publishTrack(k);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to connect to voice room:", e);
|
||||||
|
this.showWarning("Falha na conexão de voz");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startVoiceRecording() {
|
||||||
|
if (!navigator.mediaDevices) {
|
||||||
|
console.log("Media devices not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: true })
|
||||||
|
.then((s) => {
|
||||||
|
mediaRecorder = new MediaRecorder(s);
|
||||||
|
audioChunks = [];
|
||||||
|
mediaRecorder.ondataavailable = (e) => {
|
||||||
|
audioChunks.push(e.data);
|
||||||
|
};
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
const a = new Blob(audioChunks, { type: "audio/wav" });
|
||||||
|
this.simulateVoiceTranscription();
|
||||||
|
};
|
||||||
|
mediaRecorder.start();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||||
|
mediaRecorder.stop();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isVoiceMode) {
|
||||||
|
this.startVoiceRecording();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("Error accessing microphone:", e);
|
||||||
|
this.showWarning("Erro ao acessar microfone");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
simulateVoiceTranscription() {
|
||||||
|
const p = [
|
||||||
|
"Olá, como posso ajudá-lo hoje?",
|
||||||
|
"Entendo o que você está dizendo",
|
||||||
|
"Esse é um ponto interessante",
|
||||||
|
"Deixe-me pensar sobre isso",
|
||||||
|
"Posso ajudá-lo com isso",
|
||||||
|
"O que você gostaria de saber?",
|
||||||
|
"Isso parece ótimo",
|
||||||
|
"Estou ouvindo sua voz",
|
||||||
|
];
|
||||||
|
const r = p[Math.floor(Math.random() * p.length)];
|
||||||
|
if (voiceRoom) {
|
||||||
|
const m = {
|
||||||
|
type: "voice_input",
|
||||||
|
content: r,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
voiceRoom.localParticipant.publishData(
|
||||||
|
new TextEncoder().encode(JSON.stringify(m)),
|
||||||
|
LiveKitClient.DataPacketKind.RELIABLE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.addMessage("voice", `🎤 ${r}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
if (messagesDiv) {
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
isUserScrolling = false;
|
||||||
|
if (scrollToBottomBtn) {
|
||||||
|
scrollToBottomBtn.classList.remove("visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnValue = {
|
||||||
|
init: init,
|
||||||
|
current: current,
|
||||||
|
search: search,
|
||||||
|
selectedChat: selectedChat,
|
||||||
|
navItems: navItems,
|
||||||
|
chats: chats,
|
||||||
|
get filteredChats() {
|
||||||
|
return chats.filter((chat) =>
|
||||||
|
chat.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toggleSidebar: toggleSidebar,
|
||||||
|
toggleTheme: toggleTheme,
|
||||||
|
applyTheme: applyTheme,
|
||||||
|
flashScreen: flashScreen,
|
||||||
|
updateConnectionStatus: updateConnectionStatus,
|
||||||
|
getWebSocketUrl: getWebSocketUrl,
|
||||||
|
initializeAuth: initializeAuth,
|
||||||
|
loadSessions: loadSessions,
|
||||||
|
createNewSession: createNewSession,
|
||||||
|
switchSession: switchSession,
|
||||||
|
connectWebSocket: connectWebSocket,
|
||||||
|
processMessageContent: processMessageContent,
|
||||||
|
handleEvent: handleEvent,
|
||||||
|
showThinkingIndicator: showThinkingIndicator,
|
||||||
|
hideThinkingIndicator: hideThinkingIndicator,
|
||||||
|
showWarning: showWarning,
|
||||||
|
showContinueButton: showContinueButton,
|
||||||
|
continueInterruptedResponse: continueInterruptedResponse,
|
||||||
|
addMessage: addMessage,
|
||||||
|
updateStreamingMessage: updateStreamingMessage,
|
||||||
|
finalizeStreamingMessage: finalizeStreamingMessage,
|
||||||
|
escapeHtml: escapeHtml,
|
||||||
|
clearSuggestions: clearSuggestions,
|
||||||
|
handleSuggestions: handleSuggestions,
|
||||||
|
setContext: setContext,
|
||||||
|
sendMessage: sendMessage,
|
||||||
|
toggleVoiceMode: toggleVoiceMode,
|
||||||
|
startVoiceSession: startVoiceSession,
|
||||||
|
stopVoiceSession: stopVoiceSession,
|
||||||
|
connectToVoiceRoom: connectToVoiceRoom,
|
||||||
|
startVoiceRecording: startVoiceRecording,
|
||||||
|
simulateVoiceTranscription: simulateVoiceTranscription,
|
||||||
|
scrollToBottom: scrollToBottom,
|
||||||
|
cleanup: function () {
|
||||||
|
// Cleanup WebSocket connection
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
// Clear any pending timeouts/intervals
|
||||||
|
isConnecting = false;
|
||||||
|
isInitialized = false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache and return the singleton instance
|
||||||
|
chatAppInstance = returnValue;
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the app
|
||||||
|
chatApp().init();
|
||||||
|
|
||||||
|
// Listen for section changes to cleanup when leaving chat
|
||||||
|
document.addEventListener("section-hidden", function (e) {
|
||||||
|
if (
|
||||||
|
e.target.id === "section-chat" &&
|
||||||
|
chatAppInstance &&
|
||||||
|
chatAppInstance.cleanup
|
||||||
|
) {
|
||||||
|
chatAppInstance.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -1,324 +1,526 @@
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
/* App-specific styles for General Bots Desktop */
|
||||||
|
/* All modules (chat, drive, tasks, mail) use these unified theme variables */
|
||||||
|
|
||||||
|
@import url("global.css");
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
font-family:
|
||||||
background: #0f172a;
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
|
||||||
color: #e2e8f0;
|
sans-serif;
|
||||||
height: 100vh;
|
background: var(--primary-bg);
|
||||||
overflow: hidden;
|
color: var(--primary-fg);
|
||||||
}
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
/* Navbar */
|
transition:
|
||||||
nav {
|
background var(--transition-smooth),
|
||||||
background: #1e293b;
|
color var(--transition-smooth);
|
||||||
border-bottom: 2px solid #334155;
|
|
||||||
padding: 0 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
height: 60px;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav .logo {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
color: #94a3b8;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:hover {
|
|
||||||
background: #334155;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.active {
|
|
||||||
background: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main Content */
|
/* Main Content */
|
||||||
#main-content {
|
#main-content {
|
||||||
height: calc(100vh - 60px);
|
height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section {
|
.content-section {
|
||||||
display: none;
|
display: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-section.active {
|
.content-section.active {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Panel Styles */
|
/* Panel Styles - used across all modules */
|
||||||
.panel {
|
.panel {
|
||||||
background: #1e293b;
|
background: var(--glass-bg);
|
||||||
border: 1px solid #334155;
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Drive Styles */
|
.panel:hover {
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drive Layout */
|
||||||
.drive-layout {
|
.drive-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 250px 1fr 300px;
|
grid-template-columns: 250px 1fr 300px;
|
||||||
gap: 1rem;
|
gap: var(--space-md);
|
||||||
padding: 1rem;
|
padding: var(--space-lg);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-sidebar, .drive-details {
|
.drive-sidebar,
|
||||||
overflow-y: auto;
|
.drive-details {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-md);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.drive-main {
|
.drive-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 0.75rem 1rem;
|
padding: 12px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--radius-md);
|
||||||
margin: 0.25rem 0.5rem;
|
margin: 4px 0;
|
||||||
transition: background 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
background: #334155;
|
background: var(--bg-hover);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
background: #3b82f6;
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1rem;
|
padding: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
padding: 1rem;
|
padding: var(--space-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--space-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--radius-md);
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 1px solid var(--border-light);
|
||||||
transition: background 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item:hover {
|
.file-item:hover {
|
||||||
background: #334155;
|
background: var(--bg-hover);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-item.selected {
|
.file-item.selected {
|
||||||
background: #1e40af;
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-icon {
|
.file-icon {
|
||||||
font-size: 2rem;
|
font-size: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tasks Styles */
|
/* Tasks Layout */
|
||||||
.tasks-container {
|
.tasks-container {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
padding: var(--space-xl);
|
||||||
|
padding-top: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input {
|
.task-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: var(--space-sm);
|
||||||
margin-bottom: 2rem;
|
margin-bottom: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input input {
|
.task-input input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.75rem;
|
padding: 12px 20px;
|
||||||
background: #1e293b;
|
background: var(--glass-bg);
|
||||||
border: 1px solid #334155;
|
border: 2px solid var(--border-color);
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--radius-lg);
|
||||||
color: #e2e8f0;
|
color: var(--text-primary);
|
||||||
font-size: 1rem;
|
font-size: 15px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input input:focus {
|
.task-input input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #3b82f6;
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 4px var(--accent-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input input::placeholder {
|
||||||
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input button {
|
.task-input button {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 12px 24px;
|
||||||
background: #3b82f6;
|
background: var(--accent-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--radius-lg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background 0.2s;
|
font-size: 15px;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-input button:hover {
|
.task-input button:hover {
|
||||||
background: #2563eb;
|
background: var(--accent-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-list {
|
.task-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item {
|
.task-item {
|
||||||
padding: 1rem;
|
padding: var(--space-md);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: var(--space-md);
|
||||||
background: #1e293b;
|
background: var(--glass-bg);
|
||||||
border: 1px solid #334155;
|
border: 1px solid var(--glass-border);
|
||||||
border-radius: 0.5rem;
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: var(--space-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item:hover {
|
||||||
|
transform: translateX(4px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item.completed span {
|
.task-item.completed span {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item input[type="checkbox"] {
|
.task-item input[type="checkbox"] {
|
||||||
width: 1.25rem;
|
width: 20px;
|
||||||
height: 1.25rem;
|
height: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item span {
|
.task-item span {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item button {
|
.task-item button {
|
||||||
background: #ef4444;
|
background: var(--error-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 8px 16px;
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-item button:hover {
|
.task-item button:hover {
|
||||||
background: #dc2626;
|
opacity: 0.9;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters {
|
.task-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: var(--space-sm);
|
||||||
margin-top: 2rem;
|
margin-top: var(--space-xl);
|
||||||
padding-top: 2rem;
|
padding-top: var(--space-xl);
|
||||||
border-top: 1px solid #334155;
|
border-top: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button {
|
.task-filters button {
|
||||||
padding: 0.5rem 1rem;
|
padding: 10px 20px;
|
||||||
background: #334155;
|
background: var(--bg-secondary);
|
||||||
color: #e2e8f0;
|
color: var(--text-primary);
|
||||||
border: none;
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 0.375rem;
|
border-radius: var(--radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-filters button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-filters button.active {
|
.task-filters button.active {
|
||||||
background: #3b82f6;
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mail Styles */
|
/* Mail Layout */
|
||||||
.mail-layout {
|
.mail-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 250px 350px 1fr;
|
grid-template-columns: 250px 350px 1fr;
|
||||||
gap: 1rem;
|
gap: var(--space-md);
|
||||||
padding: 1rem;
|
padding: var(--space-lg);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
background: var(--primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-sidebar, .mail-list, .mail-content {
|
.mail-sidebar,
|
||||||
overflow-y: auto;
|
.mail-list,
|
||||||
|
.mail-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--glass-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-md);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-item {
|
.mail-item {
|
||||||
padding: 1rem;
|
padding: var(--space-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 1px solid var(--border-light);
|
||||||
transition: background 0.2s;
|
transition: all var(--transition-fast);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-item:hover {
|
.mail-item:hover {
|
||||||
background: #334155;
|
background: var(--bg-hover);
|
||||||
|
transform: translateX(2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-item.unread {
|
.mail-item.unread {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-item.selected {
|
.mail-item.selected {
|
||||||
background: #1e40af;
|
background: var(--accent-light);
|
||||||
|
border-color: var(--accent-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-content-view {
|
.mail-content-view {
|
||||||
padding: 2rem;
|
padding: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-header {
|
.mail-header {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: var(--space-lg);
|
||||||
padding-bottom: 1rem;
|
padding-bottom: var(--space-md);
|
||||||
border-bottom: 1px solid #334155;
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-header h2 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-header .text-sm {
|
||||||
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mail-body {
|
.mail-body {
|
||||||
line-height: 1.6;
|
line-height: 1.7;
|
||||||
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
/* Responsive adjustments for all modules */
|
||||||
button {
|
@media (max-width: 1024px) {
|
||||||
font-family: inherit;
|
.drive-layout {
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-details {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-layout {
|
||||||
|
grid-template-columns: 200px 300px 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
@media (max-width: 768px) {
|
||||||
opacity: 0.5;
|
.drive-layout {
|
||||||
cursor: not-allowed;
|
grid-template-columns: 1fr;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-sidebar,
|
||||||
|
.drive-details {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-sidebar,
|
||||||
|
.mail-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tasks-container {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
padding-top: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-input button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utility */
|
@media (max-width: 480px) {
|
||||||
h1, h2, h3 {
|
.tasks-container {
|
||||||
margin-bottom: 1rem;
|
padding: var(--space-md);
|
||||||
|
padding-top: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: var(--space-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-filters {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-sm {
|
/* Common utility classes for all modules */
|
||||||
font-size: 0.875rem;
|
.module-header {
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-xs {
|
.module-header h1 {
|
||||||
font-size: 0.75rem;
|
color: var(--text-primary);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-gray {
|
.module-toolbar {
|
||||||
color: #94a3b8;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
[x-cloak] {
|
.module-toolbar button {
|
||||||
display: none !important;
|
padding: 8px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-toolbar button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-toolbar button.primary {
|
||||||
|
background: var(--accent-color);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-toolbar button.primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state for all modules */
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-description {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation classes */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-in {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -295,18 +295,35 @@
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<!-- Loading overlay -->
|
||||||
|
<div class="loading-overlay" id="loadingOverlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Minimal floating header -->
|
<!-- Minimal floating header -->
|
||||||
<div class="float-header">
|
<div class="float-header">
|
||||||
<!-- Left: General Bots logo -->
|
<!-- Left: General Bots logo -->
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<div class="logo-wrapper" onclick="window.location.reload()">
|
<div
|
||||||
|
class="logo-wrapper"
|
||||||
|
onclick="window.location.reload()"
|
||||||
|
title="General Bots"
|
||||||
|
>
|
||||||
<div class="logo-icon"></div>
|
<div class="logo-icon"></div>
|
||||||
|
<span class="logo-text">General Bots</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Apps menu and user avatar -->
|
<!-- Right: Theme selector, Theme toggle, Apps menu and user avatar -->
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<button class="apps-menu-btn" id="appsMenuBtn" title="Apps">
|
<!-- Theme dropdown selector -->
|
||||||
|
<div id="themeSelectorContainer"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="icon-button apps-button"
|
||||||
|
id="appsButton"
|
||||||
|
title="Apps"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
|
|
@ -324,7 +341,9 @@
|
||||||
<circle cx="19" cy="19" r="2"></circle>
|
<circle cx="19" cy="19" r="2"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Apps dropdown -->
|
||||||
<div class="apps-dropdown" id="appsDropdown">
|
<div class="apps-dropdown" id="appsDropdown">
|
||||||
|
<div class="apps-dropdown-title">Applications</div>
|
||||||
<div class="app-grid">
|
<div class="app-grid">
|
||||||
<a
|
<a
|
||||||
class="app-item active"
|
class="app-item active"
|
||||||
|
|
@ -339,7 +358,7 @@
|
||||||
<span>Drive</span>
|
<span>Drive</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="app-item" href="#tasks" data-section="tasks">
|
<a class="app-item" href="#tasks" data-section="tasks">
|
||||||
<div class="app-icon">✔</div>
|
<div class="app-icon">✓</div>
|
||||||
<span>Tasks</span>
|
<span>Tasks</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="app-item" href="#mail" data-section="mail">
|
<a class="app-item" href="#mail" data-section="mail">
|
||||||
|
|
@ -348,6 +367,8 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User avatar -->
|
||||||
<div class="user-avatar" id="userAvatar" title="User Account">
|
<div class="user-avatar" id="userAvatar" title="User Account">
|
||||||
<span>U</span>
|
<span>U</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -359,14 +380,25 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Load scripts -->
|
<!-- Load scripts -->
|
||||||
|
<script src="js/theme-manager.js"></script>
|
||||||
<script src="js/layout.js"></script>
|
<script src="js/layout.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Apps menu toggle
|
// Initialize ThemeManager and Apps menu
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const appsBtn = document.getElementById("appsMenuBtn");
|
// Initialize ThemeManager
|
||||||
|
if (window.ThemeManager) {
|
||||||
|
ThemeManager.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
const appsBtn = document.getElementById("appsButton");
|
||||||
const appsDropdown = document.getElementById("appsDropdown");
|
const appsDropdown = document.getElementById("appsDropdown");
|
||||||
const appItems = document.querySelectorAll(".app-item");
|
const appItems = document.querySelectorAll(".app-item");
|
||||||
|
|
||||||
|
if (!appsBtn || !appsDropdown) {
|
||||||
|
console.error("Apps button or dropdown not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle apps menu
|
// Toggle apps menu
|
||||||
appsBtn.addEventListener("click", (e) => {
|
appsBtn.addEventListener("click", (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -403,12 +435,14 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load default section (chat) after a short delay to ensure layout.js is loaded
|
// Hide loading overlay after layout.js initializes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.switchSection) {
|
const loadingOverlay =
|
||||||
window.switchSection("chat");
|
document.getElementById("loadingOverlay");
|
||||||
|
if (loadingOverlay) {
|
||||||
|
loadingOverlay.classList.add("hidden");
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 500);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -136,8 +136,8 @@ async function switchSection(section) {
|
||||||
// Wait for the component function to be registered
|
// Wait for the component function to be registered
|
||||||
const appFunctionName = section + "App";
|
const appFunctionName = section + "App";
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
while (typeof window[appFunctionName] !== "function" && retries < 50) {
|
while (typeof window[appFunctionName] !== "function" && retries < 100) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
retries++;
|
retries++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,10 +189,18 @@ async function switchSection(section) {
|
||||||
// Remove x-ignore to allow Alpine to process
|
// Remove x-ignore to allow Alpine to process
|
||||||
wrapper.removeAttribute("x-ignore");
|
wrapper.removeAttribute("x-ignore");
|
||||||
|
|
||||||
|
// Verify component function is available
|
||||||
|
const appFunctionName = section + "App";
|
||||||
|
if (typeof window[appFunctionName] !== "function") {
|
||||||
|
console.error(`${appFunctionName} not available during Alpine init!`);
|
||||||
|
throw new Error(`Component ${appFunctionName} missing`);
|
||||||
|
}
|
||||||
|
|
||||||
// Small delay to ensure DOM is ready
|
// Small delay to ensure DOM is ready
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log(`Calling Alpine.initTree for ${section}`);
|
||||||
window.Alpine.initTree(wrapper);
|
window.Alpine.initTree(wrapper);
|
||||||
console.log(`✓ Alpine initialized for ${section}`);
|
console.log(`✓ Alpine initialized for ${section}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
117
web/desktop/js/theme-manager.js
Normal file
117
web/desktop/js/theme-manager.js
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
// Unified Theme Manager - Dropdown only, no light/dark toggle
|
||||||
|
const ThemeManager = (() => {
|
||||||
|
let currentThemeId = "default";
|
||||||
|
let subscribers = [];
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ id: "default", name: "🎨 Default", file: null },
|
||||||
|
{ id: "orange", name: "🍊 Orange", file: "orange.css" },
|
||||||
|
{ id: "cyberpunk", name: "🌃 Cyberpunk", file: "cyberpunk.css" },
|
||||||
|
{ id: "retrowave", name: "🌴 Retrowave", file: "retrowave.css" },
|
||||||
|
{ id: "vapordream", name: "💭 Vapor Dream", file: "vapordream.css" },
|
||||||
|
{ id: "y2kglow", name: "✨ Y2K", file: "y2kglow.css" },
|
||||||
|
{ id: "3dbevel", name: "🔲 3D Bevel", file: "3dbevel.css" },
|
||||||
|
{ id: "arcadeflash", name: "🕹️ Arcade", file: "arcadeflash.css" },
|
||||||
|
{ id: "discofever", name: "🪩 Disco", file: "discofever.css" },
|
||||||
|
{ id: "grungeera", name: "🎸 Grunge", file: "grungeera.css" },
|
||||||
|
{ id: "jazzage", name: "🎺 Jazz", file: "jazzage.css" },
|
||||||
|
{ id: "mellowgold", name: "🌻 Mellow", file: "mellowgold.css" },
|
||||||
|
{ id: "midcenturymod", name: "🏠 Mid Century", file: "midcenturymod.css" },
|
||||||
|
{ id: "polaroidmemories", name: "📷 Polaroid", file: "polaroidmemories.css" },
|
||||||
|
{ id: "saturdaycartoons", name: "📺 Cartoons", file: "saturdaycartoons.css" },
|
||||||
|
{ id: "seasidepostcard", name: "🏖️ Seaside", file: "seasidepostcard.css" },
|
||||||
|
{ id: "typewriter", name: "⌨️ Typewriter", file: "typewriter.css" },
|
||||||
|
{ id: "xeroxui", name: "📠 Xerox", file: "xeroxui.css" },
|
||||||
|
{ id: "xtreegold", name: "📁 XTree", file: "xtreegold.css" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function loadTheme(id) {
|
||||||
|
const theme = themes.find(t => t.id === id);
|
||||||
|
if (!theme) {
|
||||||
|
console.warn("Theme not found:", id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const old = document.getElementById("theme-css");
|
||||||
|
if (old) old.remove();
|
||||||
|
|
||||||
|
if (!theme.file) {
|
||||||
|
currentThemeId = "default";
|
||||||
|
localStorage.setItem("gb-theme", "default");
|
||||||
|
updateDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.id = "theme-css";
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = `public/themes/${theme.file}`;
|
||||||
|
link.onload = () => {
|
||||||
|
console.log("✓ Theme loaded:", theme.name);
|
||||||
|
currentThemeId = id;
|
||||||
|
localStorage.setItem("gb-theme", id);
|
||||||
|
updateDropdown();
|
||||||
|
subscribers.forEach(cb => cb({ themeId: id, themeName: theme.name }));
|
||||||
|
};
|
||||||
|
link.onerror = () => console.error("✗ Failed:", theme.name);
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDropdown() {
|
||||||
|
const dd = document.getElementById("themeDropdown");
|
||||||
|
if (dd) dd.value = currentThemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDropdown() {
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "themeDropdown";
|
||||||
|
select.className = "theme-dropdown";
|
||||||
|
themes.forEach(t => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = t.id;
|
||||||
|
opt.textContent = t.name;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.value = currentThemeId;
|
||||||
|
select.onchange = (e) => loadTheme(e.target.value);
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
let saved = localStorage.getItem("gb-theme") || "default";
|
||||||
|
if (!themes.find(t => t.id === saved)) saved = "default";
|
||||||
|
currentThemeId = saved;
|
||||||
|
loadTheme(saved);
|
||||||
|
|
||||||
|
const container = document.getElementById("themeSelectorContainer");
|
||||||
|
if (container) container.appendChild(createDropdown());
|
||||||
|
|
||||||
|
console.log("✓ Theme Manager initialized");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setThemeFromServer(data) {
|
||||||
|
if (data.logo_url) {
|
||||||
|
document.querySelectorAll(".logo-icon, .assistant-avatar").forEach(el => {
|
||||||
|
el.style.backgroundImage = `url("${data.logo_url}")`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (data.title) document.title = data.title;
|
||||||
|
if (data.logo_text) {
|
||||||
|
document.querySelectorAll(".logo-text").forEach(el => {
|
||||||
|
el.textContent = data.logo_text;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomizations() {
|
||||||
|
// Called by modules if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb) {
|
||||||
|
subscribers.push(cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, loadTheme, setThemeFromServer, applyCustomizations, subscribe, getAvailableThemes: () => themes };
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.ThemeManager = ThemeManager;
|
||||||
Loading…
Add table
Reference in a new issue