const chatScroll = document.getElementById("chatScroll"); const messagesEl = document.getElementById("messages"); const emptyState = document.getElementById("emptyState"); const threadListEl = document.getElementById("threadList"); const textInput = document.getElementById("textInput"); const sendBtn = document.getElementById("sendBtn"); const attachBtn = document.getElementById("attachBtn"); const fileInput = document.getElementById("fileInput"); const attachmentChip = document.getElementById("attachmentChip"); const attachmentName = document.getElementById("attachmentName"); const removeAttachment = document.getElementById("removeAttachment"); const newChatBtn = document.getElementById("newChatBtn"); const stepTemplate = document.getElementById("stepTemplate"); let currentThreadId = localStorage.getItem("assistant_thread_id") || null; let pendingAttachment = null; // {path, filename} let isStreaming = false; // --------------------------------------------------------------------------- // Thread sidebar // --------------------------------------------------------------------------- async function loadThreads() { const res = await fetch("/api/threads"); const threads = await res.json(); threadListEl.innerHTML = ""; threads.forEach((t) => { const item = document.createElement("div"); item.className = "thread-item" + (t.id === currentThreadId ? " active" : ""); item.innerHTML = `${escapeHtml(t.title || "নতুন কথোপকথন")}`; item.querySelector(".thread-title").addEventListener("click", () => selectThread(t.id)); item.querySelector(".del").addEventListener("click", async (e) => { e.stopPropagation(); await fetch(`/api/threads/${t.id}`, { method: "DELETE" }); if (t.id === currentThreadId) { currentThreadId = null; localStorage.removeItem("assistant_thread_id"); messagesEl.innerHTML = ""; emptyState.style.display = "block"; } loadThreads(); }); threadListEl.appendChild(item); }); } async function selectThread(threadId) { currentThreadId = threadId; localStorage.setItem("assistant_thread_id", threadId); await loadThreads(); await loadHistory(); } newChatBtn.addEventListener("click", async () => { const res = await fetch("/api/threads", { method: "POST" }); const data = await res.json(); await selectThread(data.thread_id); messagesEl.innerHTML = ""; emptyState.style.display = "block"; }); // --------------------------------------------------------------------------- // History reload (survives page refresh) // --------------------------------------------------------------------------- async function loadHistory() { if (!currentThreadId) return; const res = await fetch(`/api/history?thread_id=${encodeURIComponent(currentThreadId)}`); const data = await res.json(); messagesEl.innerHTML = ""; if (!data.turns || data.turns.length === 0) { emptyState.style.display = "block"; return; } emptyState.style.display = "none"; data.turns.forEach((turn) => { if (turn.role === "user") { renderUserMessage(turn.text); } else { const { row, stepsWrap, bubble } = renderAssistantSkeleton(); (turn.steps || []).forEach((s) => { const stepEl = createStepEl(s.type === "agent_start" ? "agent" : "tool", s.agent, s.tool, s.input); stepsWrap.appendChild(stepEl); markStepDone(stepEl, s.output !== undefined ? s.output : ""); }); bubble.textContent = turn.text || ""; if (!turn.text) row.querySelector(".bubble-wrap").style.display = (turn.steps && turn.steps.length) ? "block" : "none"; } }); scrollToBottom(); } // --------------------------------------------------------------------------- // Rendering helpers // --------------------------------------------------------------------------- function escapeHtml(str) { const div = document.createElement("div"); div.textContent = str; return div.innerHTML; } function scrollToBottom() { chatScroll.scrollTop = chatScroll.scrollHeight; } function renderUserMessage(text) { emptyState.style.display = "none"; const row = document.createElement("div"); row.className = "msg-row user"; row.innerHTML = `
`; row.querySelector(".bubble").textContent = text; messagesEl.appendChild(row); scrollToBottom(); return row; } function renderAssistantSkeleton() { emptyState.style.display = "none"; const row = document.createElement("div"); row.className = "msg-row assistant"; row.innerHTML = `
`; messagesEl.appendChild(row); scrollToBottom(); return { row, stepsWrap: row.querySelector(".steps"), bubble: row.querySelector(".bubble"), }; } const AGENT_LABELS = { git_hub_agent: "GitHub Manager Agent", git_lab_agent: "GitLab Manager Agent", facebook_agent: "Facebook Manager Agent", youtube_agent: "YouTube Manager Agent", }; function createStepEl(kind, agent, tool, input) { const node = stepTemplate.content.firstElementChild.cloneNode(true); const icon = node.querySelector(".step-icon"); const label = node.querySelector(".step-label"); const detail = node.querySelector(".step-detail"); if (kind === "agent") { icon.textContent = "🤖"; label.textContent = `${AGENT_LABELS[agent] || agent} কে কাজ দেওয়া হচ্ছে`; } else { icon.textContent = "🔧"; label.textContent = `${tool || "tool"} চালানো হচ্ছে` + (agent ? ` — ${AGENT_LABELS[agent] || agent}` : ""); } if (input) { detail.textContent = `▶ ইনপুট:\n${input}`; } node.querySelector(".step-head").addEventListener("click", () => { detail.style.display = detail.style.display === "none" ? "block" : "none"; }); return node; } function markStepDone(stepEl, output) { stepEl.classList.add("done"); stepEl.querySelector(".step-status").textContent = "সম্পন্ন"; if (output) { const detail = stepEl.querySelector(".step-detail"); detail.textContent += `\n\n▶ ফলাফল:\n${output}`; } } // --------------------------------------------------------------------------- // Attachment handling // --------------------------------------------------------------------------- attachBtn.addEventListener("click", () => fileInput.click()); fileInput.addEventListener("change", async () => { const file = fileInput.files[0]; if (!file) return; const formData = new FormData(); formData.append("file", file); const res = await fetch("/api/upload", { method: "POST", body: formData }); const data = await res.json(); pendingAttachment = data; attachmentName.textContent = `📎 ${data.filename}`; attachmentChip.style.display = "flex"; fileInput.value = ""; }); removeAttachment.addEventListener("click", () => { pendingAttachment = null; attachmentChip.style.display = "none"; }); // --------------------------------------------------------------------------- // Sending messages + streaming Manus-style action timeline // --------------------------------------------------------------------------- textInput.addEventListener("input", () => { textInput.style.height = "auto"; textInput.style.height = Math.min(textInput.scrollHeight, 160) + "px"; }); textInput.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); sendBtn.addEventListener("click", sendMessage); async function ensureThread() { if (currentThreadId) return currentThreadId; const res = await fetch("/api/threads", { method: "POST" }); const data = await res.json(); currentThreadId = data.thread_id; localStorage.setItem("assistant_thread_id", currentThreadId); return currentThreadId; } async function sendMessage() { const text = textInput.value.trim(); if (!text || isStreaming) return; await ensureThread(); renderUserMessage(text); textInput.value = ""; textInput.style.height = "auto"; const { stepsWrap, bubble } = renderAssistantSkeleton(); bubble.style.display = "block"; const attachment = pendingAttachment; pendingAttachment = null; attachmentChip.style.display = "none"; const formData = new FormData(); formData.append("thread_id", currentThreadId); formData.append("text", text); if (attachment) formData.append("attachment_path", attachment.path); isStreaming = true; sendBtn.disabled = true; const activeSteps = {}; // tool/agent key -> step element try { const res = await fetch("/api/chat", { method: "POST", body: formData }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; let evt; try { evt = JSON.parse(line); } catch { continue; } handleEvent(evt, stepsWrap, bubble, activeSteps); scrollToBottom(); } } } catch (err) { bubble.textContent = "⚠️ একটি সমস্যা হয়েছে: " + err.message; } finally { isStreaming = false; sendBtn.disabled = false; loadThreads(); } } function handleEvent(evt, stepsWrap, bubble, activeSteps) { if (evt.type === "agent_start") { const key = "agent:" + evt.agent; if (!activeSteps[key]) { const stepEl = createStepEl("agent", evt.agent, null, null); stepsWrap.appendChild(stepEl); activeSteps[key] = stepEl; markStepDone(stepEl, ""); } } else if (evt.type === "tool_start") { const key = "tool:" + evt.tool + ":" + (evt.input || ""); const stepEl = createStepEl("tool", evt.agent, evt.tool, evt.input); stepsWrap.appendChild(stepEl); activeSteps[key] = stepEl; } else if (evt.type === "tool_end") { const key = "tool:" + evt.tool + ":"; // find the most recent matching not-yet-done step for this tool const steps = stepsWrap.querySelectorAll(".step:not(.done)"); let target = null; steps.forEach((s) => { if (s.querySelector(".step-label").textContent.includes(evt.tool)) target = s; }); if (target) markStepDone(target, evt.output); } else if (evt.type === "token") { bubble.textContent += evt.text; } else if (evt.type === "error") { bubble.textContent += "\n⚠️ " + evt.message; } else if (evt.type === "done") { // finalize any steps still marked in-progress stepsWrap.querySelectorAll(".step:not(.done)").forEach((s) => markStepDone(s, "")); } } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- (async function init() { await loadThreads(); if (currentThreadId) { await loadHistory(); } })();