// Chat front for the company knowledge assistant. Streams answers from our
// FastAPI proxy at api/chat (resolved via
// 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("- " + olMatch[1] + "
");
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(""); 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 = ` `; 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 = ``; 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();