/* ═══════════════════════════════════════════════════════════════
Stacklogix — Chat Client (Vanilla JS)
═══════════════════════════════════════════════════════════════ */
const API_BASE = window.location.origin;
// ─── State ────────────────────────────────────────────────────
let currentSessionId = null;
let isWaiting = false;
// ─── DOM refs ─────────────────────────────────────────────────
const $messages = document.getElementById("messages");
const $msgContainer = document.getElementById("messagesContainer");
const $input = document.getElementById("userInput");
const $btnSend = document.getElementById("btnSend");
const $btnNewChat = document.getElementById("btnNewChat");
const $btnToggle = document.getElementById("btnToggleSidebar");
const $sidebar = document.getElementById("sidebar");
const $sessionList = document.getElementById("sessionList");
const $welcomeScreen = document.getElementById("welcomeScreen");
// ─── UUID generator ───────────────────────────────────────────
function uuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
}
// ─── Simple markdown → HTML ───────────────────────────────────
function renderMarkdown(text) {
if (!text) return "";
let html = text
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
')
// Inline code
.replace(/`([^`]+)`/g, '$1')
// Bold
.replace(/\*\*(.+?)\*\*/g, '$1')
// Italic
.replace(/\*(.+?)\*/g, '$1')
// Headers
.replace(/^### (.+)$/gm, '$1
')
.replace(/^## (.+)$/gm, '$1
')
.replace(/^# (.+)$/gm, '$1
')
// Blockquote
.replace(/^> (.+)$/gm, '$1
')
// Unordered list items
.replace(/^[-*] (.+)$/gm, '$1')
// Numbered list items
.replace(/^\d+\. (.+)$/gm, '$1');
// Wrap consecutive in
html = html.replace(/((?:- .*<\/li>\n?)+)/g, '');
// Paragraphs (lines not already wrapped in block elements)
// Allow and lines to be wrapped in
(they're inline, not block)
html = html.replace(/^(?!<[hupbol]|
- $1');
// Clean up extra newlines
html = html.replace(/\n{2,}/g, '\n');
// Highlight questions — wrap
- or
that contain a "?" in a styled box
html = html.replace(
/
- (.*?\?)\s*<\/li>/g,
'
- $1
'
);
html = html.replace(
/(.*?\?)\s*<\/p>/g,
'
'
);
return html;
}
// ─── Add message to chat ──────────────────────────────────────
function addMessage(role, content) {
// Hide welcome
if ($welcomeScreen) $welcomeScreen.style.display = "none";
const div = document.createElement("div");
div.className = `message ${role}-message`;
const avatar = document.createElement("div");
avatar.className = "message-avatar";
avatar.textContent = role === "user" ? "U" : "◆";
const bubble = document.createElement("div");
bubble.className = "message-bubble";
bubble.innerHTML = role === "assistant" ? renderMarkdown(content) : escapeHtml(content);
div.appendChild(avatar);
div.appendChild(bubble);
$messages.appendChild(div);
scrollToBottom();
}
function escapeHtml(text) {
const d = document.createElement("div");
d.textContent = text;
return d.innerHTML.replace(/\n/g, "
");
}
function scrollToBottom() {
requestAnimationFrame(() => {
$msgContainer.scrollTop = $msgContainer.scrollHeight;
});
}
// ─── Typing indicator ─────────────────────────────────────────
function showTyping() {
const div = document.createElement("div");
div.className = "typing-indicator";
div.id = "typingIndicator";
div.innerHTML = `
◆
`;
$messages.appendChild(div);
scrollToBottom();
}
function hideTyping() {
const el = document.getElementById("typingIndicator");
if (el) el.remove();
}
// ─── API calls ────────────────────────────────────────────────
// ─── Create session via API & show greeting ──────────────────
async function createNewSession() {
try {
const res = await fetch(`${API_BASE}/sessions/new`, { method: "POST" });
const data = await res.json();
currentSessionId = data.session_id;
// Clear and show greeting
$messages.innerHTML = "";
if ($welcomeScreen) {
$messages.appendChild($welcomeScreen);
$welcomeScreen.style.display = "none";
}
addMessage("assistant", data.greeting);
loadSessions();
return data.session_id;
} catch {
// Fallback: generate local id
currentSessionId = uuid();
return currentSessionId;
}
}
async function sendMessage(message) {
if (!message.trim() || isWaiting) return;
// Auto-create session if none
if (!currentSessionId) {
await createNewSession();
}
addMessage("user", message);
$input.value = "";
$input.style.height = "auto";
$btnSend.disabled = true;
isWaiting = true;
showTyping();
try {
const res = await fetch(`${API_BASE}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: currentSessionId, message }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const data = await res.json();
hideTyping();
addMessage("assistant", data.reply);
loadSessions(); // refresh sidebar
} catch (err) {
hideTyping();
addMessage("assistant", `⚠️ **Error:** ${err.message}\n\nPlease check that the server is running and try again.`);
} finally {
isWaiting = false;
$btnSend.disabled = !$input.value.trim();
}
}
async function loadSessions() {
try {
const res = await fetch(`${API_BASE}/sessions`);
const data = await res.json();
renderSessionList(data.sessions || []);
} catch { /* server might not be ready */ }
}
async function loadSessionHistory(sessionId) {
try {
const res = await fetch(`${API_BASE}/sessions/${sessionId}`);
if (!res.ok) return;
const data = await res.json();
// Clear messages
$messages.innerHTML = "";
if ($welcomeScreen) {
$messages.appendChild($welcomeScreen);
$welcomeScreen.style.display = "none";
}
// Re-render history
(data.messages || []).forEach(m => addMessage(m.role, m.content));
currentSessionId = sessionId;
highlightActiveSession();
} catch { /* ignore */ }
}
async function deleteSessionById(sessionId) {
try {
await fetch(`${API_BASE}/sessions/${sessionId}`, { method: "DELETE" });
if (currentSessionId === sessionId) {
currentSessionId = null;
$messages.innerHTML = "";
if ($welcomeScreen) {
$messages.appendChild($welcomeScreen);
$welcomeScreen.style.display = "";
}
}
loadSessions();
} catch { /* ignore */ }
}
// ─── Session list rendering ──────────────────────────────────
function renderSessionList(sessions) {
$sessionList.innerHTML = "";
sessions.forEach(s => {
const div = document.createElement("div");
div.className = "session-item" + (s.session_id === currentSessionId ? " active" : "");
div.innerHTML = `
Session · ${s.message_count || 0} msgs
`;
div.querySelector(".session-item-label").addEventListener("click", () => {
loadSessionHistory(s.session_id);
});
div.querySelector(".session-item-delete").addEventListener("click", e => {
e.stopPropagation();
deleteSessionById(s.session_id);
});
$sessionList.appendChild(div);
});
}
function highlightActiveSession() {
document.querySelectorAll(".session-item").forEach(el => el.classList.remove("active"));
// Not trivial to match back — rely on re-render from loadSessions
}
// ─── New chat ─────────────────────────────────────────────────
async function startNewChat() {
currentSessionId = null;
await createNewSession();
$input.focus();
}
// ─── Event listeners ──────────────────────────────────────────
$btnSend.addEventListener("click", () => sendMessage($input.value));
$input.addEventListener("input", () => {
$btnSend.disabled = !$input.value.trim() || isWaiting;
// Auto-resize
$input.style.height = "auto";
$input.style.height = Math.min($input.scrollHeight, 150) + "px";
});
$input.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage($input.value);
}
});
$btnNewChat.addEventListener("click", startNewChat);
$btnToggle.addEventListener("click", () => {
$sidebar.classList.toggle("hidden");
});
// Suggestion chips
document.querySelectorAll(".suggestion-chip").forEach(chip => {
chip.addEventListener("click", () => {
const msg = chip.getAttribute("data-msg");
sendMessage(msg);
});
});
// ─── Init ─────────────────────────────────────────────────────
loadSessions();
createNewSession(); // auto-start first session with greeting