387 lines
13 KiB
JavaScript
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, "&").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 <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 > above)
|
|
const bqMatch = raw.match(/^\s*>\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();
|