// Chat — native iOS chat with the Life OS LLM. Rich inline cards. // Wired to POST /api/chat — local messages state, typing indicator while waiting. function ChatScreen({ go }) { const d = LifeData; const [showMemory, setShowMemory] = React.useState(false); const [draft, setDraft] = React.useState(''); const [messages, setMessages] = React.useState([]); const [loading, setLoading] = React.useState(false); const [initialized, setInitialized] = React.useState(false); const scrollRef = React.useRef(null); const inputRef = React.useRef(null); const pollRef = React.useRef(null); const atBottomRef = React.useRef(true); const fetchHistory = async () => { try { const res = await fetch(`/api/chat/history?user=${d.user}&limit=40`); if (res.ok) { const { messages: msgs } = await res.json(); setMessages(msgs); setInitialized(true); } } catch {} }; React.useEffect(() => { fetchHistory(); pollRef.current = setInterval(fetchHistory, 5000); return () => clearInterval(pollRef.current); }, []); React.useEffect(() => { if (atBottomRef.current && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [messages, loading]); const onScroll = () => { if (!scrollRef.current) return; const { scrollTop, scrollHeight, clientHeight } = scrollRef.current; atBottomRef.current = scrollHeight - scrollTop - clientHeight < 60; }; const fmtTime = () => new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }); const send = async () => { const text = draft.trim(); if (!text || loading) return; setDraft(''); atBottomRef.current = true; const userMsg = { id: `u${Date.now()}`, role: 'user', time: fmtTime(), text }; setMessages(msgs => [...msgs, userMsg]); setLoading(true); try { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, user: d.user }), }); if (res.ok) { await fetchHistory(); } else { const asstMsg = { id: `a${Date.now()}`, role: 'assistant', time: fmtTime(), text: 'Sorry, something went wrong. Is the backend running?' }; setMessages(msgs => [...msgs, asstMsg]); } } catch (err) { const asstMsg = { id: `a${Date.now()}`, role: 'assistant', time: fmtTime(), text: 'Cannot reach Life OS backend. Check that the server is running.' }; setMessages(msgs => [...msgs, asstMsg]); } finally { setLoading(false); } }; const onKeyDown = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } }; const fillDraft = (s) => { setDraft(s); if (inputRef.current) inputRef.current.focus(); }; return (
{/* Header */}
Life OS
Synced with Telegram · Ollama · Local
setShowMemory(true)} padded={false} style={{ height: 32, padding: '0 12px' }}> Context
{/* Messages */}
{!initialized && (
Loading…
)} {initialized && messages.length === 0 && (
No messages yet.
Ask something below or send a Telegram message.
)} {messages.map((m, i) => ( ))} {/* typing indicator — only shown while waiting for LLM */} {loading && (
{[0,1,2].map(i => ( ))}
)}
{/* Composer */}
setDraft(e.target.value)} onKeyDown={onKeyDown} placeholder="Ask Life OS… /ask /brief /shop /remember" style={{ flex: 1, border: 'none', outline: 'none', background: 'transparent', fontSize: 16, color: T.label, padding: '8px 0', minWidth: 0, fontFamily: 'inherit', }} />
{/* Quick-fill chips */}
{['/brief', '/ask weather tomorrow', '/email', '/shop חלב ביצים', '/remember', '/paid'].map(s => ( fillDraft(s)} style={{ padding: '6px 11px', borderRadius: 999, background: T.bg2, color: T.label2, fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap', flexShrink: 0, border: `0.5px solid ${T.sep}`, cursor: 'pointer', }}>{s} ))}
{/* Context drawer */} {showMemory && setShowMemory(false)} />}
); } function Message({ m, prev }) { const isUser = m.role === 'user'; const sameAuthor = prev && prev.role === m.role; return (
{m.card === 'today' && } {m.card === 'task-saved' && } {m.text && (
{m.text}
)} {!sameAuthor && (
{m.time}
)}
); } function InlineTodayCard() { const d = LifeData; if (!d.calendar.length) return null; return (
Today · {d.calendar.length} events
{d.calendar.slice(0, 4).map((e, i) => (
{e.time}
{e.summary}
))}
); } function InlineTaskCard() { return (
Task saved
Added to your tasks
); } function ContextDrawer({ onClose }) { const d = LifeData; return (
e.stopPropagation()} style={{ background: T.bg, width: '100%', borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: '8px 0 28px', maxHeight: '78%', overflowY: 'auto', }}>
What I know about you
Context · {d.memories.length}
Pulled into every reply · only Ollama sees this
{d.memories.length > 0 ? ( <>
{d.memories.filter(m => !m.category || !m.category.startsWith('learned')).map((m, i, arr) => (
{m.category || 'note'}
{m.fact}
))}
{d.memories.filter(m => m.category && m.category.startsWith('learned')).length > 0 && (
{d.memories.filter(m => m.category && m.category.startsWith('learned')).map((m, i, arr) => (
{m.fact}
))}
)} ) : (
No memories yet. Use /remember to add context.
)}
Journal, finance, health & WhatsApp content never leave your Thinkpad.
Only Ollama (local) sees these.
); } window.ChatScreen = ChatScreen;