// Panels: LeftRail, TibebChat, NodeDetail, QuickAdd, TradeCard, ContextMenu const { useEffect, useRef, useState, useMemo, useCallback } = React; // ─────── small atoms ─────── function Spark({ data, color = '#7aa7ff', w = 80, h = 22 }) { if (!data?.length) return null; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * w; const y = h - ((v - min) / range) * h; return `${x.toFixed(1)},${y.toFixed(1)}`; }).join(' '); const lastY = h - ((data[data.length-1] - min) / range) * h; return ( ); } function VerifyBadge({ verdict, sources, allNodes, setFocused }) { const [open, setOpen] = useState(false); const map = { green: '✓', yellow: '·', red: '−' }; const cls = `vbadge v-${verdict}`; return ( setOpen(true)} onMouseLeave={() => setOpen(false)}> {map[verdict]} {open && ( {verdict === 'green' && 'Verified · 2+ sources'} {verdict === 'yellow' && 'Single source'} {verdict === 'red' && 'Unverified'} {sources.map(sid => { const n = allNodes.find(x => x.id === sid); if (!n) return null; return ( { e.stopPropagation(); setFocused(sid); setOpen(false); }}> {n.publisher || n.label} {n.date || ''} {n.excerpt || n.summary || ''} ); })} Why this verdict? Cross-checked against {sources.length} source{sources.length>1?'s':''}. )} ); } function SourcePill({ id, allNodes, setFocused }) { const n = allNodes.find(x => x.id === id); if (!n) return null; const label = n.publisher || n.label; return ( setFocused(id)} title={n.label} > {label} ); } // ─────── Left Rail (workspace switcher) ─────── function LeftRail({ collapsed, setCollapsed, webs, activeWebId, setActiveWebId, onCreateWeb, onRenameWeb, onDeleteWeb }) { const [renamingId, setRenamingId] = useState(null); if (collapsed) { return ( ); } return ( ); } // ─────── Tibeb Chat ─────── const CHAT_STORAGE_KEY = 'jarvis.chat.v3'; function loadChat(webId) { try { const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}'); return all[webId] || null; } catch (_) { return null; } } // Phase 3.B — fire-and-forget server-side persistence on send. Local // cache stays authoritative for the UI; the server sync just makes the // thread portable across devices. Only runs for real persisted users. function persistChatRemote(webId, message) { try { const u = window.TIBEB_USER; if (!u || !u.auth_configured || u.is_local) return; if (!message || !message.role || (!message.text && !message.actions)) return; fetch(`/api/webs/${encodeURIComponent(webId)}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: message.role, text: message.text || '', sources: message.sources || null, actions: message.actions || null, actionState: message.actionState || null, }), }).catch(() => {}); } catch (_) {} } function saveChat(webId, messages) { try { const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}'); // Only persist serializable fields all[webId] = messages.map(m => ({ role: m.role, text: typeof m.text === 'string' ? m.text : null, sources: m.sources || null, actions: m.actions || null, actionState: m.actionState || null, })).filter(m => m.text !== null || m.actions); localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(all)); } catch(_) {} } // Public surface for non-TibebChat components (e.g. inventHypothesis in // app.jsx, the trade-card "send to chat" flow, Mad Max result handlers) to // drop a message into the persistent chat thread without owning React state. // Dispatches a CustomEvent which TibebChat listens for and merges into its // messages state. Falls back to localStorage if nobody is mounted yet. window.tibebAppendChat = function appendChat(webId, message) { if (!webId || !message || !message.text) return; // Persist to storage so it survives a remount. try { const all = JSON.parse(localStorage.getItem(CHAT_STORAGE_KEY) || '{}'); const prior = Array.isArray(all[webId]) ? all[webId] : []; all[webId] = [...prior, { role: message.role || 'jarvis', text: message.text, sources: message.sources || null, actions: message.actions || null, actionState: message.actionState || null, }]; localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(all)); } catch (_) {} // And dispatch so a mounted TibebChat picks it up live. try { window.dispatchEvent(new CustomEvent('tibeb:chat:append', { detail: { webId, message }, })); } catch (_) {} // Phase 3.B — server-persist for logged-in users. persistChatRemote(webId, message); }; function TibebChat({ allNodes, edges, activeWeb, tweaks, selected, focused, setFocused, onConnectTrade, tradeProposal, onAcceptTrade, onDismissTrade, tradeAccepted, tradeDismissed, onRefineTrade, onApplyActions, collapsed, setCollapsed }) { const webId = activeWeb.id; const [messages, setMessages] = useState(() => { const saved = loadChat(webId); if (saved && saved.length) return saved; return [{ role: 'jarvis', text: `Hi — I'm Tibeb. I can see all ${allNodes.length} nodes in "${activeWeb.name}" and I'll keep my memory across refreshes.\n\nAsk me anything about the web, or tell me to add/connect/rename nodes — I'll propose the edit and you accept it with one click. Try: "add a thesis that rates stay higher for longer" or "connect [t1] to NVDA as supports".`, }]; }); const [input, setInput] = useState(''); const [listening, setListening] = useState(false); const [typing, setTyping] = useState(false); const inputRef = useRef(null); const scrollRef = useRef(null); useEffect(() => { window.__focusTibeb = () => inputRef.current?.focus(); return () => { window.__focusTibeb = null; }; }, []); // Reload messages when web changes. Phase 3.B — when a real user is // logged in, hydrate from the server first; localStorage is the local // cache that gives instant paint while the API fetch lands. The two // layers are eventually-consistent: server wins after the fetch, the // user sees their cross-device chat history. useEffect(() => { const saved = loadChat(webId); if (saved && saved.length) setMessages(saved); else setMessages([{ role: 'jarvis', text: `Switched to "${activeWeb.name}". Fresh memory for this web — ask me anything.`, }]); // Server hydrate, fire-and-forget, only for persisted users const u = window.TIBEB_USER; const isPersistedUser = !!(u && u.auth_configured && !u.is_local); if (!isPersistedUser) return; let cancelled = false; (async () => { try { const r = await fetch(`/api/webs/${encodeURIComponent(webId)}/chat?limit=200`); if (!r.ok) return; const j = await r.json(); const remote = (j.messages || []).map(m => ({ role: m.role, text: m.text, sources: m.sources || undefined, actions: m.actions || undefined, actionState: m.actionState || undefined, })); if (cancelled) return; if (remote.length > 0) setMessages(remote); } catch (_) { // Non-fatal — local cache is fine } })(); return () => { cancelled = true; }; }, [webId]); // Persist locally on every change useEffect(() => { saveChat(webId, messages); }, [messages, webId]); // Listen for external pushes from window.tibebAppendChat. inventHypothesis // (and other top-level flows) emit a 'tibeb:chat:append' event when they // want to land a status message in the chat thread without owning React // state. We only accept events targeted at the currently active web. useEffect(() => { const onAppend = (e) => { if (!e || !e.detail) return; if (e.detail.webId !== webId) return; const msg = e.detail.message; if (!msg || !msg.text) return; setMessages(m => [...m, { role: msg.role || 'jarvis', text: msg.text, sources: msg.sources || undefined, actions: msg.actions || undefined, actionState: msg.actionState || undefined, }]); }; window.addEventListener('tibeb:chat:append', onAppend); return () => window.removeEventListener('tibeb:chat:append', onAppend); }, [webId]); useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }, [messages, typing]); const send = (text) => { const userText = text || input.trim(); if (!userText) return; const userMsg = { role: 'user', text: userText }; setMessages(m => [...m, userMsg]); persistChatRemote(webId, userMsg); // Phase 3.B — fire-and-forget setInput(''); setTyping(true); // Phase 6 R · #F — Streaming render. Append a placeholder assistant // message immediately and update its `text` as chunks arrive. Once // the stream completes, parse out any ```actions JSON block and // finalize the message with sources + actions. const streamMsgId = `stream_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; setMessages(m => [...m, { role: 'jarvis', text: '', sources: [], actions: null, actionState: null, _streaming: true, _streamId: streamMsgId, }]); const onChunk = (_piece, full) => { setMessages(m => m.map(msg => msg._streamId === streamMsgId ? { ...msg, text: full } : msg )); }; askTibeb(userText, allNodes, edges, activeWeb, tweaks, [...messages, userMsg], { selected, focused, onChunk }) .then(reply => { setTyping(false); const { text: cleanText, actions } = parseActions(reply); const finalMsg = { role: 'jarvis', text: cleanText, sources: extractCitedIds(cleanText, allNodes), actions: actions.length ? actions : null, actionState: null, }; setMessages(m => m.map(msg => msg._streamId === streamMsgId ? finalMsg : msg )); persistChatRemote(webId, finalMsg); }) .catch((err) => { setTyping(false); const friendly = (window.friendlyChatError && window.friendlyChatError(err)) || "Something went wrong on the model side. Try again or rephrase."; const errMsg = { role: 'jarvis', text: friendly }; setMessages(m => m.map(msg => msg._streamId === streamMsgId ? errMsg : msg )); persistChatRemote(webId, errMsg); }); }; const handleActionDecision = (msgIdx, decision) => { setMessages(m => m.map((msg, i) => { if (i !== msgIdx) return msg; if (decision === 'accept') { const result = onApplyActions(msg.actions); return { ...msg, actionState: 'accepted', actionResult: result }; } return { ...msg, actionState: 'rejected' }; })); }; const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; // ─────── Voice loop ─────── // Web Speech API (recognition) + browser SpeechSynthesis (TTS) + // continuous-mode toggle. Supports barge-in: starting the mic while TTS is // speaking cancels the TTS so the user can interrupt at any time. const recognitionRef = useRef(null); const [voiceMode, setVoiceMode] = useState(false); // continuous flag const [speaking, setSpeaking] = useState(false); // TTS active const [liveTranscript, setLiveTranscript] = useState(''); const lastSpokenIdxRef = useRef(-1); // dedup TTS per message const sendRef = useRef(send); sendRef.current = send; // Init SpeechRecognition once useEffect(() => { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SR) return; // Firefox / unsupported — chat still works via typing const r = new SR(); r.continuous = false; // we drive continuous mode ourselves r.interimResults = true; r.lang = 'en-US'; r.onstart = () => setListening(true); r.onend = () => setListening(false); r.onerror = (e) => { setListening(false); if (e.error && e.error !== 'no-speech' && e.error !== 'aborted') { console.warn('[voice] recognition error:', e.error); } }; r.onresult = (e) => { let interim = ''; let final = ''; for (let i = e.resultIndex; i < e.results.length; i++) { const t = e.results[i][0].transcript; if (e.results[i].isFinal) final += t; else interim += t; } if (final) { setLiveTranscript(''); setInput(''); sendRef.current(final.trim()); } else { setLiveTranscript(interim); } }; recognitionRef.current = r; return () => { try { r.stop(); } catch(_) {} }; }, []); // ElevenLabs audio playback element — created lazily on first speak. const audioElRef = useRef(null); // Speak the latest Tibeb reply while voice mode is on. Routes through // /api/voice/speak (ElevenLabs Flash v2.5 with SETTINGS.elevenlabs_voice_id // override) so the user hears the same custom voice configured for // their Convai agent — NOT the browser's robotic SpeechSynthesis. // Falls back to browser TTS only when the backend is unreachable. // Auto-restarts the mic after speech ends so the conversation flows. useEffect(() => { if (!voiceMode) return; const lastIdx = messages.length - 1; const last = messages[lastIdx]; if (!last || last.role !== 'jarvis') return; if (lastSpokenIdxRef.current >= lastIdx) return; lastSpokenIdxRef.current = lastIdx; // Strip markdown/citations for cleaner spoken output const spokenText = (last.text || '') .replace(/\*\*([^*]+)\*\*/g, '$1') // bold .replace(/\*([^*]+)\*/g, '$1') // italic .replace(/`([^`]+)`/g, '$1') // inline code .replace(/\[s_?[a-z0-9]+\]/gi, '') // citation pills .replace(/\[t\d+\]|\[k_[A-Z]+\]|\[c\d+\]/g, '') // node refs .trim(); if (!spokenText) return; setSpeaking(true); // Shared "speech ended" hook — restarts the mic for the next turn. const onDone = () => { setSpeaking(false); if (voiceMode && recognitionRef.current) { try { recognitionRef.current.start(); } catch(_) {} } }; (async () => { // Two error surfaces: // 1. fetch / blob fails → backend outage → browser TTS fallback // 2. audio.play() rejects (autoplay policy) → DO NOT fall back // to robotic SpeechSynthesis; just surface the issue so the // user knows to click first. let audioReady = false; try { const resp = await fetch('/api/voice/speak', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: spokenText.slice(0, 1500) }), }); if (!resp.ok) throw new Error(`speak ${resp.status}`); const blob = await resp.blob(); const url = URL.createObjectURL(blob); if (!audioElRef.current) { audioElRef.current = new Audio(); audioElRef.current.preload = 'auto'; } const el = audioElRef.current; el.src = url; el.onended = () => { onDone(); URL.revokeObjectURL(url); }; el.onerror = () => { onDone(); }; audioReady = true; try { await el.play(); } catch (playErr) { // Autoplay blocked — audio exists, browser won't play yet. // eslint-disable-next-line no-console console.warn('[panels] audio.play() blocked by autoplay policy. Click any input to enable audio.'); onDone(); } } catch (err) { // ElevenLabs unreachable. NO browser-TTS fallback — silence // is the honest signal. Robotic voice is explicitly unwanted. // eslint-disable-next-line no-console console.warn('[panels] elevenlabs speak failed (silent — no robotic fallback):', err); onDone(); } })(); }, [messages, voiceMode]); const toggleVoice = () => { const r = recognitionRef.current; if (!r) { // No browser support — show a one-shot tip setMessages(m => [...m, { role: 'jarvis', text: "Your browser doesn't support Web Speech recognition. Try Chrome or Safari." }]); return; } // Barge-in: cancel any in-flight TTS so the user can interrupt. // We now use an