Introduce chatApp singleton and guarded init
- Implement singleton pattern for chatApp to prevent multiple instances - Gate initialization with isInitialized to skip repeated init calls - Add guards for auth and WebSocket connections to avoid overlaps - Filter non-message payloads and only render content messages - Improve scroll-to-bottom button visibility and interaction - Update scrollbar styling, including dark theme rules
This commit is contained in:
parent
cfbff7f35c
commit
9f1d74b101
4 changed files with 248 additions and 92 deletions
11
src/main.rs
11
src/main.rs
|
|
@ -134,16 +134,19 @@ async fn run_axum_server(
|
|||
let static_path = std::path::Path::new("./web/desktop");
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(crate::web_server::index))
|
||||
.merge(api_router)
|
||||
.with_state(app_state.clone())
|
||||
// Static file services must come first to match before other routes
|
||||
.nest_service("/js", ServeDir::new(static_path.join("js")))
|
||||
.nest_service("/css", ServeDir::new(static_path.join("css")))
|
||||
.nest_service("/drive", ServeDir::new(static_path.join("drive")))
|
||||
.nest_service("/chat", ServeDir::new(static_path.join("chat")))
|
||||
.nest_service("/mail", ServeDir::new(static_path.join("mail")))
|
||||
.nest_service("/tasks", ServeDir::new(static_path.join("tasks")))
|
||||
.fallback_service(ServeDir::new(static_path))
|
||||
// API routes
|
||||
.merge(api_router)
|
||||
.with_state(app_state.clone())
|
||||
// Root index route - only matches exact "/"
|
||||
.route("/", get(crate::web_server::index))
|
||||
// Layers
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http());
|
||||
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ body::before {
|
|||
}
|
||||
#messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding: 20px 20px 140px;
|
||||
max-width: 680px;
|
||||
|
|
@ -431,17 +431,21 @@ footer {
|
|||
color: var(--bg);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
z-index: 90;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px var(--shadow);
|
||||
}
|
||||
.scroll-to-bottom.visible {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.scroll-to-bottom:hover {
|
||||
transform: scale(1.1) rotate(180deg);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
.warning-message {
|
||||
border-radius: 12px;
|
||||
|
|
@ -612,7 +616,7 @@ footer {
|
|||
opacity: 0.7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* Claude-style scrollbar - thin, subtle, hover-only */
|
||||
/* Claude-style scrollbar - thin, subtle, always visible but more prominent on hover */
|
||||
#messages::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
|
@ -620,21 +624,24 @@ footer {
|
|||
background: transparent;
|
||||
}
|
||||
#messages::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
#messages:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(128, 128, 128, 0.3);
|
||||
background: rgba(128, 128, 128, 0.4);
|
||||
}
|
||||
#messages::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(128, 128, 128, 0.5);
|
||||
background: rgba(128, 128, 128, 0.6);
|
||||
}
|
||||
[data-theme="dark"] #messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
[data-theme="dark"] #messages:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
[data-theme="dark"] #messages::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* Fallback for other elements */
|
||||
|
|
|
|||
|
|
@ -1,8 +1,22 @@
|
|||
// 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;
|
||||
o,
|
||||
isConnecting = false,
|
||||
isInitialized = false,
|
||||
authPromise = null;
|
||||
((currentSessionId = null),
|
||||
(currentUserId = null),
|
||||
(currentBotId = "default_bot"),
|
||||
|
|
@ -159,6 +173,13 @@ function chatApp() {
|
|||
// 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");
|
||||
|
|
@ -196,34 +217,35 @@ function chatApp() {
|
|||
// UI event listeners
|
||||
document.addEventListener("click", (e) => {});
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
// 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();
|
||||
});
|
||||
scrollToBottomBtn.addEventListener("click", () => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
sendBtn.onclick = () => this.sendMessage();
|
||||
messageInputEl.addEventListener("keypress", (e) => {
|
||||
if (e.key === "Enter") this.sendMessage();
|
||||
});
|
||||
window.addEventListener("focus", () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
this.connectWebSocket();
|
||||
}
|
||||
});
|
||||
|
||||
// Start authentication flow
|
||||
// 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();
|
||||
});
|
||||
},
|
||||
|
|
@ -258,22 +280,48 @@ function chatApp() {
|
|||
},
|
||||
|
||||
async initializeAuth() {
|
||||
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;
|
||||
this.connectWebSocket();
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize auth:", e);
|
||||
this.updateConnectionStatus("disconnected");
|
||||
setTimeout(() => this.initializeAuth(), 3000);
|
||||
// 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() {
|
||||
|
|
@ -331,14 +379,37 @@ function chatApp() {
|
|||
},
|
||||
|
||||
connectWebSocket() {
|
||||
if (ws) {
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -357,12 +428,14 @@ function chatApp() {
|
|||
};
|
||||
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();
|
||||
|
|
@ -380,6 +453,7 @@ function chatApp() {
|
|||
};
|
||||
ws.onerror = (e) => {
|
||||
console.error("WebSocket error:", e);
|
||||
isConnecting = false;
|
||||
this.updateConnectionStatus("disconnected");
|
||||
};
|
||||
},
|
||||
|
|
@ -389,6 +463,12 @@ function chatApp() {
|
|||
isContextChange = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore messages without content
|
||||
if (!r.content && r.is_complete !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.context_usage !== undefined) {
|
||||
this.updateContextUsage(r.context_usage);
|
||||
}
|
||||
|
|
@ -401,7 +481,8 @@ function chatApp() {
|
|||
isStreaming = false;
|
||||
streamingMessageId = null;
|
||||
currentStreamingContent = "";
|
||||
} else {
|
||||
} else if (r.content) {
|
||||
// Only add message if there's actual content
|
||||
this.addMessage("assistant", r.content, false);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -842,11 +923,67 @@ function chatApp() {
|
|||
},
|
||||
|
||||
scrollToBottom() {
|
||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||
isUserScrolling = false;
|
||||
scrollToBottomBtn.classList.remove("visible");
|
||||
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,
|
||||
updateContextUsage: updateContextUsage,
|
||||
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,
|
||||
};
|
||||
|
||||
// Cache and return the singleton instance
|
||||
chatAppInstance = returnValue;
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
// Initialize the app
|
||||
|
|
|
|||
|
|
@ -1,43 +1,52 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>General Bots</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>General Bots</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script defer src="js/alpine.js"></script>
|
||||
</head>
|
||||
|
||||
<script defer src="js/alpine.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav x-data="{ current: 'chat' }">
|
||||
<div class="logo">⚡ General Bots</div>
|
||||
<a
|
||||
href="#chat"
|
||||
@click.prevent="current = 'chat'; window.switchSection('chat')"
|
||||
:class="{ active: current === 'chat' }"
|
||||
>💬 Chat</a
|
||||
>
|
||||
|
||||
<body>
|
||||
<nav x-data="{ current: 'chat' }">
|
||||
<div class="logo">⚡ General Bots</div>
|
||||
<a href="#chat" @click.prevent="current = 'chat'; window.switchSection('chat')"
|
||||
:class="{ active: current === 'chat' }">💬 Chat</a>
|
||||
<a
|
||||
href="#drive"
|
||||
@click.prevent="current = 'drive'; window.switchSection('drive')"
|
||||
:class="{ active: current === 'drive' }"
|
||||
>📁 Drive</a
|
||||
>
|
||||
<a
|
||||
href="#tasks"
|
||||
@click.prevent="current = 'tasks'; window.switchSection('tasks')"
|
||||
:class="{ active: current === 'tasks' }"
|
||||
>✓ Tasks</a
|
||||
>
|
||||
<a
|
||||
href="#mail"
|
||||
@click.prevent="current = 'mail'; window.switchSection('mail')"
|
||||
:class="{ active: current === 'mail' }"
|
||||
>✉ Mail</a
|
||||
>
|
||||
</nav>
|
||||
|
||||
<a href="#drive" @click.prevent="current = 'drive'; window.switchSection('drive')"
|
||||
:class="{ active: current === 'drive' }">📁 Drive</a>
|
||||
<a href="#tasks" @click.prevent="current = 'tasks'; window.switchSection('tasks')"
|
||||
:class="{ active: current === 'tasks' }">✓ Tasks</a>
|
||||
<a href="#mail" @click.prevent="current = 'mail'; window.switchSection('mail')"
|
||||
:class="{ active: current === 'mail' }">✉ Mail</a>
|
||||
</nav>
|
||||
<div id="main-content">
|
||||
<!-- Sections will be loaded dynamically -->
|
||||
</div>
|
||||
|
||||
<div id="main-content">
|
||||
<!-- Sections will be loaded dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Load Module Scripts -->
|
||||
<script src="js/layout.js"></script>
|
||||
<script src="chat/chat.js"></script>
|
||||
<script src="drive/drive.js"></script>
|
||||
<script src="tasks/tasks.js"></script>
|
||||
<script src="mail/mail.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<!-- Load Layout Script - module scripts are loaded dynamically -->
|
||||
<script src="js/layout.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue