// Chat front for the company knowledge assistant. Streams answers from our // FastAPI proxy at api/chat (resolved via ), which fans out to // AnythingLLM. Renders incremental textResponseChunk events, then a final // block of source chips when the stream closes. const log = document.getElementById("chat-log"); const form = document.getElementById("chat-form"); const input = document.getElementById("chat-input"); const sendBtn = document.getElementById("chat-send"); const clearBtn = document.getElementById("chat-clear"); let sessionId = sessionStorage.getItem("asistent_session_id") || null; let isStreaming = false; // ── Helpers ──────────────────────────────────────── function escapeHtml(s) { return s .replace(/&/g, "&").replace(//g, ">"); } // Markdown → HTML. Uses a paragraph-buffer model: plain lines accumulate into // the current paragraph and only flush on blank line or block-level transition. // This avoids the bug where "**Heading:**\n- bullet" emitted an extra
// between the bold paragraph and the list (visible as a blank line). function renderMarkdown(md) { let html = escapeHtml(md); // Fenced code blocks first so md inside isn't processed. We mark them with // a placeholder so they don't interfere with the line-by-line loop, then // re-insert at the end. const codeBlocks = []; html = html.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_, lang, body) => { codeBlocks.push( `
${body.replace(/\n$/, "")}
` ); return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`; }); // Inline code (single line) html = html.replace(/`([^`\n]+)`/g, "$1"); // Inline emphasis + links (done early because we use joined-paragraph text) html = html.replace(/\*\*(.+?)\*\*/g, "$1"); html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1$2"); html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); const lines = html.split("\n"); const out = []; let paragraph = []; // buffer of plain lines for the current

let inUL = false, inOL = false, inBQ = false; const flushParagraph = () => { if (paragraph.length === 0) return; // CommonMark: a single \n inside a paragraph is a space, not a break. const text = paragraph.join(" ").replace(/\s+/g, " ").trim(); if (text) out.push("

" + text + "

"); paragraph = []; }; const closeLists = () => { if (inUL) { out.push(""); inUL = false; } if (inOL) { out.push(""); inOL = false; } if (inBQ) { out.push(""); inBQ = false; } }; for (const raw of lines) { // Blank line → end of current paragraph / block if (raw.trim() === "") { flushParagraph(); closeLists(); continue; } // Code-block placeholder (already wrapped in
)
    if (/^\x00CODEBLOCK\d+\x00$/.test(raw.trim())) {
      flushParagraph();
      closeLists();
      out.push(raw);
      continue;
    }

    // Header
    const hMatch = raw.match(/^\s*(#{1,6})\s+(.+?)\s*$/);
    if (hMatch) {
      flushParagraph();
      closeLists();
      const level = hMatch[1].length;
      out.push(`${hMatch[2]}`);
      continue;
    }

    // Ordered list "1. foo"
    const olMatch = raw.match(/^\s*\d+[.)]\s+(.+)$/);
    if (olMatch) {
      flushParagraph();
      if (inUL) { out.push(""); inUL = false; }
      if (inBQ) { out.push(""); inBQ = false; }
      if (!inOL) { out.push("
    "); inOL = true; } out.push("
  1. " + olMatch[1] + "
  2. "); continue; } // Unordered list "- foo", "* foo", "+ foo" const ulMatch = raw.match(/^\s*[-*+]\s+(.+)$/); if (ulMatch) { flushParagraph(); if (inOL) { out.push("
"); inOL = false; } if (inBQ) { out.push(""); inBQ = false; } if (!inUL) { out.push(""); inUL = false; } if (inOL) { out.push(""); inOL = false; } if (!inBQ) { out.push("
"); inBQ = true; } out.push("

" + bqMatch[1] + "

"); continue; } // Plain text — accumulate into current paragraph closeLists(); paragraph.push(raw); } flushParagraph(); closeLists(); let result = out.join(""); // Re-insert code blocks result = result.replace(/\x00CODEBLOCK(\d+)\x00/g, (_, n) => codeBlocks[+n]); return result; } // ── Message construction ─────────────────────────── function addUserMessage(text) { const el = document.createElement("div"); el.className = "msg msg-user"; el.innerHTML = `
Vy
`; el.querySelector(".msg-body").textContent = text; log.appendChild(el); el.scrollIntoView({ behavior: "smooth", block: "end" }); } function addAssistantPlaceholder() { const el = document.createElement("div"); el.className = "msg msg-assistant msg-typing"; el.innerHTML = `
Asistent
`; log.appendChild(el); el.scrollIntoView({ behavior: "smooth", block: "end" }); return el; } function addAssistantActions(msgEl, finalText) { const body = msgEl.querySelector(".msg-body"); const actions = document.createElement("div"); actions.className = "msg-actions"; actions.innerHTML = ` `; body.appendChild(actions); actions.querySelector('[data-action="copy"]').addEventListener("click", () => { navigator.clipboard.writeText(finalText).then(() => { const span = actions.querySelector('[data-action="copy"] span'); const prev = span.textContent; span.textContent = "Zkopírováno"; setTimeout(() => { span.textContent = prev; }, 1500); }); }); } function attachSources(msgEl, sources) { if (!sources || !sources.length) return; const seen = new Set(); const wrap = document.createElement("div"); wrap.className = "sources"; const label = document.createElement("div"); label.className = "sources-label"; label.textContent = "Zdroje:"; wrap.appendChild(label); const chips = document.createElement("div"); chips.className = "sources-chips"; wrap.appendChild(chips); for (const s of sources) { const title = s.title || s.chunkSource || "dokument"; if (seen.has(title)) continue; seen.add(title); const chip = document.createElement("span"); chip.className = "source-chip"; chip.innerHTML = ` `; chip.querySelector(".source-chip-name").textContent = title; chips.appendChild(chip); } msgEl.querySelector(".msg-body").appendChild(wrap); } // ── Streaming ────────────────────────────────────── async function send(message) { if (isStreaming) return; isStreaming = true; sendBtn.disabled = true; input.disabled = true; // After the first message, hide the welcome card if it's still here. const welcome = document.getElementById("welcome"); if (welcome) welcome.remove(); addUserMessage(message); const msgEl = addAssistantPlaceholder(); const body = msgEl.querySelector(".msg-body"); let accumulated = ""; let receivedFirstChunk = false; try { const r = await fetch("api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, session_id: sessionId }), }); if (!r.ok || !r.body) throw new Error("HTTP " + r.status); const respSession = r.headers.get("X-Session-Id"); if (respSession) { sessionId = respSession; sessionStorage.setItem("asistent_session_id", sessionId); } const reader = r.body.getReader(); const decoder = new TextDecoder(); let buf = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); let idx; while ((idx = buf.indexOf("\n\n")) !== -1) { const raw = buf.slice(0, idx); buf = buf.slice(idx + 2); if (!raw.startsWith("data:")) continue; const json = raw.slice(5).trim(); if (!json) continue; let evt; try { evt = JSON.parse(json); } catch (e) { console.warn("bad SSE json", json); continue; } if (evt.type === "textResponseChunk") { if (!receivedFirstChunk) { msgEl.classList.remove("msg-typing"); body.innerHTML = ""; receivedFirstChunk = true; } accumulated += evt.textResponse || ""; body.innerHTML = renderMarkdown(accumulated); msgEl.scrollIntoView({ block: "end" }); } else if (evt.type === "finalizeResponseStream") { if (!receivedFirstChunk) { msgEl.classList.remove("msg-typing"); body.innerHTML = ""; } const final = evt.textResponse || accumulated; if (final) { body.innerHTML = renderMarkdown(final); accumulated = final; } attachSources(msgEl, evt.sources || []); if (accumulated) addAssistantActions(msgEl, accumulated); msgEl.scrollIntoView({ block: "end" }); } else if (evt.type === "error") { msgEl.classList.remove("msg-typing"); body.innerHTML = `

Chyba: ${escapeHtml(evt.error || "neznámá")}

`; } } } if (!receivedFirstChunk && !accumulated) { msgEl.classList.remove("msg-typing"); body.textContent = "Asistent neodpověděl. Zkuste otázku zopakovat."; } } catch (exc) { console.error(exc); msgEl.classList.remove("msg-typing"); body.innerHTML = `

Spojení selhalo: ${escapeHtml(String(exc))}

`; } finally { isStreaming = false; sendBtn.disabled = false; input.disabled = false; input.focus(); } } // ── Clear conversation ───────────────────────────── async function clearConversation() { if (isStreaming) return; // Start a fresh session so AnythingLLM doesn't carry old context. sessionId = null; sessionStorage.removeItem("asistent_session_id"); // Wipe the log and restore the welcome card. log.innerHTML = ""; const welcome = document.createElement("div"); welcome.id = "welcome"; welcome.className = "welcome"; welcome.innerHTML = `

Vítejte!

Jsem firemní asistent. Odpovídám na otázky podle interních dokumentů z intranetu.

Zkuste se zeptat například:

`; log.appendChild(welcome); welcome.querySelectorAll(".starter").forEach((btn) => { btn.addEventListener("click", () => { if (isStreaming) return; send(btn.textContent); }); }); input.focus(); } // ── Input behaviour ──────────────────────────────── function autoGrow() { input.style.height = "auto"; input.style.height = Math.min(input.scrollHeight, 200) + "px"; } input.addEventListener("input", autoGrow); input.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } }); form.addEventListener("submit", (e) => { e.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ""; autoGrow(); send(text); }); if (clearBtn) clearBtn.addEventListener("click", clearConversation); document.querySelectorAll(".starter").forEach((btn) => { btn.addEventListener("click", () => { if (isStreaming) return; send(btn.textContent); }); }); input.focus();