const { useEffect, useMemo, useRef, useState } = React;
marked.setOptions({ gfm: true, breaks: true });
function cn(...parts) {
return parts.filter(Boolean).join(" ");
}
function uid() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function renderMd(text, sources = []) {
const html = DOMPurify.sanitize(marked.parse(text || ""));
if (!sources.length) return html;
const sourceByIndex = new Map(
sources.map((source) => [String(source.index), source]),
);
const doc = new DOMParser().parseFromString(
`
${html}
`,
"text/html",
);
const root = doc.body.firstElementChild;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!/\[(\d+)\]/.test(node.nodeValue || ""))
return NodeFilter.FILTER_REJECT;
if (node.parentElement?.closest("a, code, pre"))
return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
},
});
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
const textValue = node.nodeValue || "";
const fragment = doc.createDocumentFragment();
let lastIndex = 0;
for (const match of textValue.matchAll(/\[(\d+)\]/g)) {
const source = sourceByIndex.get(match[1]);
if (!source) continue;
if (match.index > lastIndex) {
fragment.append(
doc.createTextNode(textValue.slice(lastIndex, match.index)),
);
}
const link = doc.createElement("a");
link.className = "citation-link";
link.textContent = match[0];
link.href = sourceUrl(source) || `#source-${source.index}`;
if (sourceUrl(source)) {
link.target = "_blank";
link.rel = "noopener noreferrer";
}
fragment.append(link);
lastIndex = match.index + match[0].length;
}
if (lastIndex < textValue.length) {
fragment.append(doc.createTextNode(textValue.slice(lastIndex)));
}
node.parentNode.replaceChild(fragment, node);
}
return root.innerHTML;
}
function Button({ className, children, ...props }) {
return (
);
}
const Input = React.forwardRef(function Input({ className, ...props }, ref) {
return ;
});
const Textarea = React.forwardRef(function Textarea(
{ className, ...props },
ref,
) {
return (
);
});
function Badge({ variant = "secondary", className, children }) {
return (
{children}
);
}
function Spinner() {
return ;
}
function sourceUrl(source) {
const cid = String(source.entity_cid || "").trim();
if (!cid) return null;
const encCid = encodeURIComponent(cid);
switch (source.entity_type) {
case "rule": {
const bookCid = String(source.book_cid || "").trim();
const filter = bookCid ? `filter=${encodeURIComponent(bookCid)}&` : "";
return `https://sirael.dracihlidka.cz/rules?${filter}cid=${encCid}`;
}
case "ability":
return `https://sirael.dracihlidka.cz/abilities/${encCid}`;
case "spell":
return `https://sirael.dracihlidka.cz/spells/${encCid}`;
case "skill":
return `https://sirael.dracihlidka.cz/skills/${encCid}`;
case "npc":
case "monster":
return `https://sirael.dracihlidka.cz/npcs/${encCid}`;
case "quest":
return `https://sirael.dracihlidka.cz/quests/${encCid}`;
default:
return null;
}
}
function usagePercent(used, limit) {
if (!limit) return 0;
return Math.min(100, Math.max(0, Math.round((used / limit) * 100)));
}
function UsageMeter({ usage }) {
const minute = usagePercent(
usage?.used_minute || 0,
usage?.limit_minute || 0,
);
const daily = usagePercent(usage?.used_day || 0, usage?.limit_day || 0);
return (
minute
{minute}%
daily
{daily}%
);
}
function tokenRoles(tokenParsed, clientId) {
const realmRoles = tokenParsed?.realm_access?.roles || [];
const clientRoles = tokenParsed?.resource_access?.[clientId]?.roles || [];
return [...new Set([...realmRoles, ...clientRoles])].sort();
}
function AuthUser({ auth }) {
if (!auth?.enabled || !auth.email) return null;
return (
{auth.email}
);
}
function BookAccess({ books }) {
if (!books?.length) return null;
return (
{books.map((book) => (
{book}
))}
);
}
function ViewSwitch({ view, onChange }) {
return (
{["chat", "sessions"].map((item) => (
))}
);
}
function SourceLink({ source }) {
const label = `[${source.index}] ${source.entity_type}/${source.name || source.entity_cid}`;
const meta = source.book_cid
? ` (${source.book_cid}${source.page ? ` p.${source.page}` : ""})`
: "";
const href = sourceUrl(source);
if (!href)
return (
{label}
{meta}
);
return (
{label}
{meta}
);
}
function AssistantMessage({ text, sources }) {
const html = useMemo(() => renderMd(text, sources || []), [text, sources]);
return (
{sources && sources.length > 0 ? (
sources:
{sources.map((source, index) => (
))}
) : null}
);
}
function UserMessage({ text }) {
return {text};
}
function ErrorMessage({ text }) {
return {text};
}
function EventRow({ event }) {
return (
{event.label}
{event.code ? {event.code} : event.text}
);
}
function EventsPanel({ events, status }) {
return (
{events.map((event) => (
))}
{status ? (
status
{status}
) : null}
);
}
function ChatItem({ item }) {
if (item.type === "user") return ;
if (item.type === "assistant")
return ;
if (item.type === "events")
return ;
if (item.type === "error") return ;
return null;
}
function EmptyState() {
return (
Sirael CHAT
Zeptej se na pravidla, postavy, kouzla nebo lore. Odpovědi budou
doplněné zdroji.
);
}
function formatDateTime(value) {
if (!value) return "";
return new Date(value).toLocaleString("cs-CZ", {
dateStyle: "short",
timeStyle: "medium",
});
}
function formatCost(value) {
const cost = Number(value || 0);
return `$${cost.toFixed(6)}`;
}
function AdminSessions({ sessions, status, onRefresh }) {
return (
sessions
{status ? {status}
: null}
{sessions.map((session) => (
{formatDateTime(session.finished_at)}
{session.user_email ||
session.user_subject ||
session.usage_key ||
"anonymous"}
{session.status}
{session.total_tokens} tok
{formatCost(session.estimated_cost_usd)}
books:{" "}
{(session.source_books || []).length
? session.source_books.join(", ")
: "all"}
{session.question}
{session.answer ? (
{session.answer}
) : null}
))}
);
}
async function* sseLines(resp) {
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2);
let ev = "message";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:")) ev = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
yield { ev, data: data ? JSON.parse(data) : {} };
}
}
}
function App() {
const [messages, setMessages] = useState([]);
const [usage, setUsage] = useState(null);
const [question, setQuestion] = useState("");
const [isSending, setIsSending] = useState(false);
const [auth, setAuth] = useState({
ready: false,
enabled: false,
keycloak: null,
email: "",
roles: [],
allowedRole: "rag-user",
adminRole: "rag-admin",
books: [],
});
const [authError, setAuthError] = useState("");
const [view, setView] = useState("chat");
const [adminSessions, setAdminSessions] = useState([]);
const [adminStatus, setAdminStatus] = useState("");
const scrollRef = useRef(null);
const textareaRef = useRef(null);
const isAdmin = auth.roles.includes(auth.adminRole);
useEffect(() => {
let cancelled = false;
async function initAuth() {
try {
const configResp = await fetch("/auth/config");
if (!configResp.ok)
throw new Error(`${configResp.status}: auth config failed`);
const config = await configResp.json();
if (!config.enabled) {
if (!cancelled) {
setAuth({
ready: true,
enabled: false,
keycloak: null,
email: "",
roles: config.devRoles || [],
allowedRole: config.allowedRole || "rag-user",
adminRole: config.adminRole || "rag-admin",
books: config.books || [],
});
}
return;
}
const keycloak = new Keycloak({
url: config.url,
realm: config.realm,
clientId: config.clientId,
});
await keycloak.init({
onLoad: "login-required",
pkceMethod: "S256",
checkLoginIframe: false,
});
if (cancelled) return;
const roles = tokenRoles(keycloak.tokenParsed, config.clientId);
const allowedRole = config.allowedRole || "rag-user";
if (!roles.includes(allowedRole)) {
throw new Error(`missing required role: ${allowedRole}`);
}
const authedConfigResp = await fetch("/auth/config", {
headers: { authorization: `Bearer ${keycloak.token}` },
});
const authedConfig = authedConfigResp.ok
? await authedConfigResp.json()
: config;
setAuth({
ready: true,
enabled: true,
keycloak,
email:
keycloak.tokenParsed?.email ||
keycloak.tokenParsed?.preferred_username ||
"",
roles,
allowedRole,
adminRole: authedConfig.adminRole || config.adminRole || "rag-admin",
books: authedConfig.books || [],
});
} catch (err) {
if (!cancelled) {
setAuth({
ready: true,
enabled: false,
keycloak: null,
email: "",
roles: [],
allowedRole: "rag-user",
adminRole: "rag-admin",
books: [],
});
setAuthError(String(err));
}
}
}
initAuth();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const node = scrollRef.current;
if (node) node.scrollTop = node.scrollHeight;
}, [messages]);
async function authHeaders() {
if (!auth.enabled || !auth.keycloak) return {};
await auth.keycloak.updateToken(30);
return { authorization: `Bearer ${auth.keycloak.token}` };
}
async function loadAdminSessions() {
if (!isAdmin) return;
setAdminStatus("loading");
try {
const resp = await fetch("/api/admin/chat-sessions?limit=20", {
headers: await authHeaders(),
});
if (!resp.ok) throw new Error(`${resp.status}: sessions failed`);
const data = await resp.json();
setAdminSessions(data.sessions || []);
setAdminStatus("");
} catch (err) {
setAdminStatus(String(err));
}
}
useEffect(() => {
if (!isAdmin && view !== "chat") setView("chat");
if (isAdmin && view === "sessions") loadAdminSessions();
}, [isAdmin, view]);
function updateEvents(id, patch) {
setMessages((current) =>
current.map((item) => {
if (item.id !== id || item.type !== "events") return item;
return { ...item, ...patch };
}),
);
}
function appendEvent(id, event) {
setMessages((current) =>
current.map((item) => {
if (item.id !== id || item.type !== "events") return item;
return {
...item,
events: [...item.events, { id: uid(), ...event }],
};
}),
);
}
function markLastSearchEvent(id) {
setMessages((current) =>
current.map((item) => {
if (item.id !== id || item.type !== "events") return item;
const events = [...item.events];
for (let index = events.length - 1; index >= 0; index -= 1) {
if (events[index].kind === "search") {
events[index] = { ...events[index], label: "searched" };
break;
}
}
return { ...item, events };
}),
);
}
async function send(message) {
const eventsId = uid();
setMessages((current) => [
...current,
{ id: uid(), type: "user", text: message },
{ id: eventsId, type: "events", events: [], status: "starting" },
]);
let final = null;
let finalSources = [];
const resp = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"content-type": "application/json",
...(await authHeaders()),
},
body: JSON.stringify({ message }),
});
if (!resp.ok || !resp.body) {
updateEvents(eventsId, { status: "" });
setMessages((current) => [
...current,
{
id: uid(),
type: "error",
text: `${resp.status}: stream failed`,
},
]);
return;
}
for await (const { ev, data } of sseLines(resp)) {
switch (ev) {
case "usage":
setUsage(data);
break;
case "status":
updateEvents(eventsId, { status: data.text || "" });
break;
case "tool_call":
appendEvent(eventsId, {
kind: "search",
label: "search",
code: data.args?.query || "",
});
updateEvents(eventsId, { status: "searching" });
break;
case "tool_result":
markLastSearchEvent(eventsId);
updateEvents(eventsId, { status: "thinking" });
break;
case "sources":
finalSources = data.sources || [];
break;
case "answer":
final = data.text || "";
break;
case "error":
appendEvent(eventsId, {
label: "error",
text: data.message || "error",
error: true,
});
updateEvents(eventsId, { status: "" });
break;
case "done":
updateEvents(eventsId, { status: "" });
if (final !== null) {
setMessages((current) => [
...current,
{
id: uid(),
type: "assistant",
text: final,
sources: finalSources,
},
]);
}
if (isAdmin) await loadAdminSessions();
return;
}
}
updateEvents(eventsId, { status: "" });
}
async function handleSubmit(event) {
event.preventDefault();
const message = question.trim();
if (!message || isSending) return;
setQuestion("");
setIsSending(true);
try {
await send(message);
} catch (err) {
setMessages((current) => [
...current,
{ id: uid(), type: "error", text: String(err) },
]);
} finally {
setIsSending(false);
requestAnimationFrame(() => textareaRef.current?.focus());
}
}
function handleQuestionKeyDown(event) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}
if (!auth.ready) {
return (
Sirael Chat
Přihlašuji...
);
}
if (authError) {
return (
);
}
return (
S
Sirael Chat
{isAdmin ?
: null}
{isAdmin && view === "sessions" ? (
) : (
{messages.length === 0 ? (
) : (
messages.map((item) => )
)}
)}
{view === "chat" ? (
) : null}
);
}
ReactDOM.createRoot(document.getElementById("root")).render();