987 lines
30 KiB
Text
987 lines
30 KiB
Text
|
|
// 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();
|
|||
|
|
}
|
|||
|
|
});
|