// Prussian Bot — chat view: message list, reasoning block, tool cards, composer.
const { useState, useRef, useEffect, useLayoutEffect, useMemo } = React;
const MACRON = /[āēīōūĀĒĪŌŪ]/;
function Markdown({ text }) {
const ref = useRef(null);
const html = useMemo(() => text ? marked.parse(text) : '', [text]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const iter = document.createNodeIterator(el, NodeFilter.SHOW_TEXT, null);
const pending = [];
let node;
while ((node = iter.nextNode())) {
if (node.textContent && MACRON.test(node.textContent)) {
pending.push(node);
}
}
for (const n of pending) {
const wrap = document.createElement('span');
wrap.innerHTML = n.textContent.replace(/(\S*[āēīōūĀĒĪŌŪ]\S*)/g, '$1');
n.replaceWith(...wrap.childNodes);
}
}, [html]);
if (!text) return null;
return
;
}
/* ---- reasoning block (always visible, collapsible) ---- */
function Reasoning({ text, streaming }) {
const [open, setOpen] = useState(true);
if (!text) return null;
return (
setOpen(!open)}>
▼
{t('reasoning.head')}{streaming ? ' …' : ''}
{open &&
{text}{streaming && }
}
);
}
/* ---- single tool call card ---- */
function ToolCard({ call }) {
const [open, setOpen] = useState(false);
const status = call.status || 'run'; // run | ok | err
const led = status === 'ok' ? 'ok' : status === 'err' ? 'err' : 'run';
let preview = call.args || '';
try { if (call.args) preview = JSON.stringify(JSON.parse(call.args)); } catch (_) {}
return (
setOpen(!open)}>
▸
{call.name}
{preview}
{call.ms != null && status !== 'run' && {call.ms} ms}
{open && (
{t('tool.args')}
{pretty(call.args)}
{status !== 'run' && (
{status === 'err' ? t('tool.error') : t('tool.result')}
{call.result != null ? call.result : (status === 'err' ? (call.error || t('tool.error')) : '…')}
)}
{status === 'run' &&
{t('tool.runningLabel')}{t('tool.running')} }
)}
);
}
function pretty(s) {
if (s == null || s === '') return '{}';
try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return s; }
}
/* ---- error card with retry ---- */
function ErrorCard({ error, onRetry }) {
return (
{error.isRateLimit ? t('error.rateLimit') : (error.source === 'mcp' ? t('error.mcp') : t('error.generic'))}
{error.message}
{onRetry && (
)}
);
}
/* ---- one message (user or assistant) ---- */
function Message({ turn, isLast, streaming, onRetry }) {
if (turn.role === 'user') {
return (
{t('user.label')}
{turn.content}
);
}
const calls = turn.toolCalls || [];
const reasoningStreaming = streaming && isLast && !turn.content && !turn.error;
const contentStreaming = streaming && isLast && !!turn.content;
return (
{t('bot.label')}
{turn.reasoning ?
: null}
{calls.length > 0 && (
{calls.map((c, i) => )}
)}
{turn.content ? (
{contentStreaming && }
) : null}
{turn.error ?
: null}
{streaming && isLast && !turn.content && !turn.reasoning && calls.length === 0 && !turn.error && (
{t('thinking')}
)}
);
}
/* ---- welcome / empty state ---- */
function Welcome({ onPick, configured }) {
var welcomeQs = [
{ q: t('welcome.q1'), a: null },
{ q: t('welcome.q2'), a: null },
{ q: t('welcome.q3'), a: null },
];
return (
{t('welcome.title')}
{t('welcome.description')}
{!configured &&
{t('welcome.notConfigured')}
}
{welcomeQs.map(function(item, i) {
return (
);
})}
);
}
/* ---- composer ---- */
function Composer({ onSend, busy, onStop, disabled }) {
const [text, setText] = useState('');
const ref = useRef(null);
function grow() {
const el = ref.current; if (!el) return;
el.style.height = 'auto'; el.style.height = Math.min(el.scrollHeight, 180) + 'px';
}
useEffect(grow, [text]);
function submit() {
const t = text.trim(); if (!t || busy) return;
onSend(t); setText('');
}
function key(e) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
}
return (
);
}
/* keep thread pinned to bottom while streaming */
function useStickyScroll(ref, dep) {
const stick = useRef(true);
useEffect(() => {
const el = ref.current; if (!el) return;
const onScroll = () => { stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80; };
el.addEventListener('scroll', onScroll);
return () => el.removeEventListener('scroll', onScroll);
}, []);
useLayoutEffect(() => {
const el = ref.current; if (el && stick.current) el.scrollTop = el.scrollHeight;
}, [dep]);
}
Object.assign(window, { Markdown, Reasoning, ToolCard, ErrorCard, Message, Welcome, Composer, useStickyScroll });