// 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.name} : {card.glyph}} {card.n} {card.name} {card.key}
); } function Message({ msg, speed, onTick }) { if (msg.role === "user") { return (
{msg.text}
); } return (
Azoth AI
{msg.cards && }
{msg.streaming && !msg.text ? вдивляюся… : msg.typing ? : msg.text}
{msg.sources && msg.sources.length > 0 && (
{msg.sources.slice(0, 5).map((s, i) => ( {s.title || s.url} ))}
)}
); } 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) && (
Azoth AI
вдивляюся…
)}
{msgs.length <= 1 && (
{(SUGGESTIONS[active] || []).map((s, i) => ( ))}
)}