// chat.jsx - інтерфейс бесіди з Azoth AI
const { useState, useRef, useEffect, useCallback } = React;
/* ---- typewriter ---- */
function Typewriter({ text, speed = 22, onTick, onDone }) {
const [n, setN] = useState(0);
useEffect(() => {
setN(0);
if (!text) return;
let i = 0;
const id = setInterval(() => {
i += 1; setN(i);
onTick && onTick();
if (i >= text.length) { clearInterval(id); onDone && onDone(); }
}, speed);
return () => clearInterval(id);
}, [text, speed]);
const done = n >= text.length;
return (
{text.slice(0, n)}
{!done && ✦}
);
}
/* ---- tarot card with flip ---- */
function TarotCard({ card, delay = 0, flipped }) {
return (
✦
{card.img
?

:
{card.glyph}}
{card.n}
{card.name}
{card.key}
);
}
function Message({ msg, speed, onTick }) {
if (msg.role === "user") {
return (
);
}
return (
Azoth AI
{msg.cards &&
}
{msg.streaming && !msg.text
? вдивляюся…
: msg.typing
?
: msg.text}
{msg.sources && msg.sources.length > 0 && (
)}
);
}
function TarotSpread({ cards }) {
const [flipped, setFlipped] = useState([]);
useEffect(() => {
cards.forEach((_, i) => {
setTimeout(() => setFlipped(f => [...f, i]), 500 + i * 650);
});
}, []);
return (
{cards.map((c, i) => (
{SPREAD_LABELS[i] || ""}
))}
);
}
const SPREAD_LABELS = ["Минуле", "Теперішнє", "Прийдешнє"];
function buildSpreadContext(cards) {
if (!cards || !cards.length) return "";
const cardList = cards
.map((card, i) => `${SPREAD_LABELS[i] || `Позиція ${i + 1}`}: ${card.name} — ${card.key}`)
.join("; ");
return `\n\n[Карти розкладу, які вже відкриті в інтерфейсі: ${cardList}. Тлумач саме ці карти й ці позиції; не замінюй їх іншими арканами.]`;
}
function Chat({ seeker, onSignOut, speed = 22 }) {
const disc = window.DISCIPLINES;
const REPLIES = window.REPLIES, SUGGESTIONS = window.SUGGESTIONS, drawCards = window.drawCards;
const [active, setActive] = useState((seeker?.disciplines && seeker.disciplines[0]) || "free");
const [sidebar, setSidebar] = useState(true);
const [input, setInput] = useState("");
const [msgs, setMsgs] = useState([]);
const [busy, setBusy] = useState(false);
const [threads, setThreads] = useState([]);
const [activeThreadId, setActiveThreadId] = useState(null);
const [loadingThread, setLoadingThread] = useState(false);
const scrollRef = useRef(null);
const taRef = useRef(null);
const activeDisc = disc.find(d => d.id === active);
const scrollDown = useCallback(() => {
const el = scrollRef.current; if (el) el.scrollTop = el.scrollHeight;
}, []);
useEffect(() => { scrollDown(); }, [msgs, scrollDown]);
const localGreeting = useCallback(() => ({
id: "greeting",
role: "agent",
text: REPLIES.greeting.replace("{name}", seeker?.name || "мандрівник"),
typing: false,
}), [REPLIES, seeker?.name]);
async function refreshThreads(selectFirst = false) {
try {
const data = await window.AzothApi.threads();
setThreads(data.threads || []);
if (selectFirst && data.threads && data.threads[0]) loadThread(data.threads[0].id);
if (selectFirst && (!data.threads || !data.threads[0])) startNew();
} catch (err) {
setMsgs([{ id: "auth-error", role: "agent", text: "Сесія згасла. Увійди ще раз.", typing: false }]);
}
}
useEffect(() => { refreshThreads(true); }, []);
async function loadThread(threadId) {
setLoadingThread(true);
setBusy(false);
setActiveThreadId(threadId);
try {
const data = await window.AzothApi.history(threadId);
setMsgs((data.messages || []).map(m => ({
id: m.id,
role: m.role === "assistant" ? "agent" : "user",
text: m.content,
typing: false,
sources: m.meta?.sources || [],
})));
} catch (err) {
setMsgs([{ id: "load-error", role: "agent", text: "Не вдалося відкрити цю бесіду.", typing: false }]);
} finally {
setLoadingThread(false);
}
}
function startNew() {
setActiveThreadId(null);
setBusy(false);
setMsgs([localGreeting()]);
}
function pushAgent(text, cards) {
const id = Date.now() + Math.random();
setBusy(true);
// brief "divining" pause, then type
setTimeout(() => {
setMsgs(m => [...m, {
id, role: "agent", text, cards, typing: true,
onDone: () => { setBusy(false); setMsgs(mm => mm.map(x => x.id === id ? { ...x, typing: false } : x)); }
}]);
}, cards ? 200 : 650);
}
function send(text) {
const q = (text ?? input).trim();
if (!q || busy) return;
setInput("");
if (taRef.current) taRef.current.style.height = "auto";
setMsgs(m => [...m, { id: Date.now(), role: "user", text: q }]);
const lc = q.toLowerCase();
const wantsSpread = active === "tarot" || /карт|розклад|тар/.test(lc);
const spreadCards = wantsSpread ? drawCards(3) : null;
const agentId = Date.now() + Math.random() + "-agent";
setBusy(true);
setMsgs(m => [...m, {
id: agentId,
role: "agent",
text: "",
cards: spreadCards,
typing: false,
streaming: true,
sources: [],
}]);
window.AzothApi.streamChat({
message: `${active === "free" ? q : `[${activeDisc.name}] ${q}`}${buildSpreadContext(spreadCards)}`,
threadId: activeThreadId,
search: null,
}, {
thread: ({ thread_id }) => setActiveThreadId(thread_id),
sources: ({ sources }) => setMsgs(m => m.map(x => x.id === agentId ? { ...x, sources: sources || [] } : x)),
delta: ({ text }) => setMsgs(m => m.map(x => x.id === agentId ? { ...x, text: x.text + (text || "") } : x)),
error: ({ message }) => {
setMsgs(m => m.map(x => x.id === agentId ? {
...x,
text: `Завіса затремтіла: ${message || "відповідь обірвалася"}`,
streaming: false,
} : x));
setBusy(false);
},
done: () => {
setMsgs(m => m.map(x => x.id === agentId ? { ...x, streaming: false } : x));
setBusy(false);
refreshThreads(false);
},
}).catch(err => {
setMsgs(m => m.map(x => x.id === agentId ? {
...x,
text: `Завіса затремтіла: ${err.message || err}`,
streaming: false,
} : x));
setBusy(false);
});
}
function onKey(e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
}
function grow(e) {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = Math.min(e.target.scrollHeight, 160) + "px";
}
return (
{/* ---- sidebar ---- */}
{/* ---- main ---- */}
{!sidebar && }
{activeDisc.glyph}
{activeDisc.name}
{activeDisc.lat}
Завіса тонка
{msgs.map(m => (
))}
{(busy || loadingThread) && !msgs.some(m => m.typing || m.streaming) && (
)}
{msgs.length <= 1 && (
{(SUGGESTIONS[active] || []).map((s, i) => (
))}
)}
Azoth AI допомагає практику бачити структуру, джерела й ризики. Рішення сесії лишаються за тобою.
);
}
Object.assign(window, { Chat });