Files
2026-07-07 13:16:26 +02:00

387 lines
13 KiB
JavaScript

// Chat front for the company knowledge assistant. Streams answers from our
// FastAPI proxy at api/chat (resolved via <base href>), 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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 <br>
// 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(
`<pre><code class="lang-${lang || "text"}">${body.replace(/\n$/, "")}</code></pre>`
);
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Inline code (single line)
html = html.replace(/`([^`\n]+)`/g, "<code>$1</code>");
// Inline emphasis + links (done early because we use joined-paragraph text)
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
const lines = html.split("\n");
const out = [];
let paragraph = []; // buffer of plain lines for the current <p>
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("<p>" + text + "</p>");
paragraph = [];
};
const closeLists = () => {
if (inUL) { out.push("</ul>"); inUL = false; }
if (inOL) { out.push("</ol>"); inOL = false; }
if (inBQ) { out.push("</blockquote>"); 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 <pre>)
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(`<h${level}>${hMatch[2]}</h${level}>`);
continue;
}
// Ordered list "1. foo"
const olMatch = raw.match(/^\s*\d+[.)]\s+(.+)$/);
if (olMatch) {
flushParagraph();
if (inUL) { out.push("</ul>"); inUL = false; }
if (inBQ) { out.push("</blockquote>"); inBQ = false; }
if (!inOL) { out.push("<ol>"); inOL = true; }
out.push("<li>" + olMatch[1] + "</li>");
continue;
}
// Unordered list "- foo", "* foo", "+ foo"
const ulMatch = raw.match(/^\s*[-*+]\s+(.+)$/);
if (ulMatch) {
flushParagraph();
if (inOL) { out.push("</ol>"); inOL = false; }
if (inBQ) { out.push("</blockquote>"); inBQ = false; }
if (!inUL) { out.push("<ul>"); inUL = true; }
out.push("<li>" + ulMatch[1] + "</li>");
continue;
}
// Blockquote "> foo" (escaped to &gt; above)
const bqMatch = raw.match(/^\s*&gt;\s+(.+)$/);
if (bqMatch) {
flushParagraph();
if (inUL) { out.push("</ul>"); inUL = false; }
if (inOL) { out.push("</ol>"); inOL = false; }
if (!inBQ) { out.push("<blockquote>"); inBQ = true; }
out.push("<p>" + bqMatch[1] + "</p>");
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 = `
<div class="msg-author">Vy</div>
<div class="msg-body"></div>
`;
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 = `
<div class="msg-author">
<span class="msg-avatar" aria-hidden="true">A</span>
<span>Asistent</span>
</div>
<div class="msg-body">
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
</div>
`;
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 = `
<button type="button" class="action-btn" data-action="copy" title="Zkopírovat odpověď">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
<span>Kopírovat</span>
</button>
`;
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 = `
<svg width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span class="source-chip-name"></span>
`;
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 =
`<p class="msg-error"><strong>Chyba:</strong> ${escapeHtml(evt.error || "neznámá")}</p>`;
}
}
}
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 =
`<p class="msg-error"><strong>Spojení selhalo:</strong> ${escapeHtml(String(exc))}</p>`;
} 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 = `
<h2>Vítejte!</h2>
<p>Jsem firemní asistent. Odpovídám na otázky podle interních dokumentů z intranetu.</p>
<p class="welcome-sub">Zkuste se zeptat například:</p>
<ul class="starter-list">
<li><button class="starter">Jak si zažádám o dovolenou?</button></li>
<li><button class="starter">Jaké jsou benefity ke vzdělávání?</button></li>
<li><button class="starter">Jak vyúčtuji služební cestu?</button></li>
<li><button class="starter">Co dělat při pracovním úrazu?</button></li>
</ul>
`;
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();