import React, { useState, useEffect, useRef, useCallback } from 'react'; import ReactDOM from 'react-dom'; import { X, User as UserIcon, Sparkles, Check, Trash2, Pencil, Plus, RefreshCw, Loader2, MessageSquareQuote, Eye, } from 'lucide-react'; import ProfileWalkthrough from './ProfileWalkthrough'; const FACT_CATEGORIES = [ { value: 'person', label: 'Person' }, { value: 'organization', label: 'Organization' }, { value: 'environment', label: 'Devices & environment' }, { value: 'needs', label: 'Needs' }, { value: 'preferences', label: 'Preferences' }, ]; const overlay = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }; const modal = { background: 'var(--bg-primary)', borderRadius: 16, padding: 0, width: 560, maxWidth: '95vw', maxHeight: '85vh', overflow: 'hidden', boxShadow: 'var(--shadow-xl)', display: 'flex', flexDirection: 'column', }; const header = { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '20px 24px', borderBottom: '1px solid var(--border-primary)', }; const tabRow = { display: 'flex', gap: 4, padding: '12px 16px 0', borderBottom: '1px solid var(--border-primary)', flexWrap: 'wrap', }; const tabBtn = (active) => ({ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', minHeight: 44, background: 'transparent', border: 'none', borderBottom: active ? '2px solid var(--accent-primary)' : '2px solid transparent', color: active ? 'var(--accent-primary)' : 'var(--text-secondary)', cursor: 'pointer', fontSize: 13.5, fontWeight: 500, marginBottom: -1, }); const body = { padding: 24, overflowY: 'auto', flex: 1 }; const label = { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6, }; const input = { width: '100%', padding: '10px 12px', borderRadius: 8, minHeight: 44, border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 14, boxSizing: 'border-box', }; const primaryBtn = { padding: '10px 16px', minHeight: 44, background: 'var(--accent-fill, var(--accent-primary))', color: 'var(--accent-on-accent, #fff)', border: 'none', borderRadius: 8, cursor: 'pointer', fontSize: 14, fontWeight: 500, display: 'inline-flex', alignItems: 'center', gap: 8, }; const ghostBtn = { padding: '10px 14px', minHeight: 44, minWidth: 44, background: 'transparent', border: '1px solid var(--border-primary)', borderRadius: 8, cursor: 'pointer', fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6, }; const sectionTitle = { margin: '0 0 6px', fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', }; const sectionHint = { margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.45, }; const factCard = { border: '1px solid var(--border-primary)', borderRadius: 10, padding: '12px 14px', marginBottom: 10, background: 'var(--bg-secondary)', }; const extractError = (data, fallback) => { if (!data) return fallback; if (typeof data.detail === 'string') return data.detail; if (Array.isArray(data.detail) && data.detail[0]?.msg) return data.detail[0].msg; return fallback; }; const AboutYouModal = ({ authToken, onClose, existingProfile, initialTab = 'about', }) => { const [activeTab, setActiveTab] = useState(initialTab === 'profile' ? 'profile' : 'about'); const [facts, setFacts] = useState([]); const [summaries, setSummaries] = useState({ short: '', long: '' }); const [loading, setLoading] = useState(true); const [message, setMessage] = useState(null); const [busyId, setBusyId] = useState(null); const [regenerating, setRegenerating] = useState(false); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [showAdd, setShowAdd] = useState(false); const [newFact, setNewFact] = useState({ category: 'person', key: '', value: '' }); const [adding, setAdding] = useState(false); const apiUrl = process.env.REACT_APP_API_URL || ''; const mouseDownOnOverlay = useRef(false); const handleOverlayMouseDown = (e) => { mouseDownOnOverlay.current = e.target === e.currentTarget; }; const handleOverlayMouseUp = (e) => { if (mouseDownOnOverlay.current && e.target === e.currentTarget) onClose(); mouseDownOnOverlay.current = false; }; const authHeaders = useCallback(() => ({ Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json', }), [authToken]); const loadData = useCallback(async () => { setLoading(true); setMessage(null); try { const [factsResp, sumsResp] = await Promise.all([ fetch(`${apiUrl}/api/users/me/facts`, { headers: { Authorization: `Bearer ${authToken}` } }), fetch(`${apiUrl}/api/users/me/summaries`, { headers: { Authorization: `Bearer ${authToken}` } }), ]); if (factsResp.ok) { const data = await factsResp.json(); setFacts(Array.isArray(data.facts) ? data.facts : []); } else { setMessage({ type: 'error', text: 'Could not load profile facts.' }); } if (sumsResp.ok) { const data = await sumsResp.json(); setSummaries({ short: data.short || '', long: data.long || '' }); } } catch { setMessage({ type: 'error', text: 'Network error loading knowledge profile.' }); } finally { setLoading(false); } }, [apiUrl, authToken]); useEffect(() => { if (activeTab === 'about') loadData(); }, [activeTab, loadData]); useEffect(() => { setActiveTab(initialTab === 'profile' ? 'profile' : 'about'); }, [initialTab]); const statedFacts = facts.filter((f) => f.source === 'stated'); const inferredFacts = facts.filter((f) => f.source === 'inferred'); const handleConfirm = async (fact) => { setBusyId(fact.id); setMessage(null); try { const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ source: 'stated' }), }); const data = await resp.json().catch(() => null); if (!resp.ok) { setMessage({ type: 'error', text: extractError(data, 'Could not confirm fact.') }); return; } setFacts((prev) => prev.map((f) => (f.id === fact.id ? { ...f, ...data, source: 'stated' } : f))); setMessage({ type: 'success', text: 'Fact confirmed as something you told us.' }); } catch { setMessage({ type: 'error', text: 'Network error.' }); } finally { setBusyId(null); } }; const handleDelete = async (fact) => { setBusyId(fact.id); setMessage(null); try { const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${authToken}` }, }); if (!resp.ok && resp.status !== 204) { const data = await resp.json().catch(() => null); setMessage({ type: 'error', text: extractError(data, 'Could not delete fact.') }); return; } setFacts((prev) => prev.filter((f) => f.id !== fact.id)); if (editingId === fact.id) { setEditingId(null); setEditValue(''); } } catch { setMessage({ type: 'error', text: 'Network error.' }); } finally { setBusyId(null); } }; const startEdit = (fact) => { setEditingId(fact.id); setEditValue(fact.value || ''); setMessage(null); }; const cancelEdit = () => { setEditingId(null); setEditValue(''); }; const saveEdit = async (fact) => { const trimmed = editValue.trim(); if (!trimmed) { setMessage({ type: 'error', text: 'Value cannot be empty.' }); return; } setBusyId(fact.id); setMessage(null); try { const resp = await fetch(`${apiUrl}/api/users/me/facts/${fact.id}`, { method: 'PUT', headers: authHeaders(), body: JSON.stringify({ value: trimmed }), }); const data = await resp.json().catch(() => null); if (!resp.ok) { setMessage({ type: 'error', text: extractError(data, 'Could not update fact.') }); return; } setFacts((prev) => prev.map((f) => (f.id === fact.id ? { ...f, ...data } : f))); setEditingId(null); setEditValue(''); } catch { setMessage({ type: 'error', text: 'Network error.' }); } finally { setBusyId(null); } }; const handleAdd = async () => { const key = newFact.key.trim(); const value = newFact.value.trim(); if (!key || !value) { setMessage({ type: 'error', text: 'Key and value are required.' }); return; } setAdding(true); setMessage(null); try { const resp = await fetch(`${apiUrl}/api/users/me/facts`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ category: newFact.category, key, value, }), }); const data = await resp.json().catch(() => null); if (!resp.ok) { setMessage({ type: 'error', text: extractError(data, 'Could not add fact.') }); return; } setFacts((prev) => [...prev, data]); setNewFact({ category: 'person', key: '', value: '' }); setShowAdd(false); setMessage({ type: 'success', text: 'Fact added.' }); } catch { setMessage({ type: 'error', text: 'Network error.' }); } finally { setAdding(false); } }; const handleRegenerate = async () => { setRegenerating(true); setMessage(null); try { const resp = await fetch(`${apiUrl}/api/users/me/summaries/regenerate`, { method: 'POST', headers: { Authorization: `Bearer ${authToken}` }, }); const data = await resp.json().catch(() => null); if (!resp.ok) { setMessage({ type: 'error', text: extractError(data, 'Could not regenerate summaries.') }); return; } setSummaries({ short: data.short || '', long: data.long || '' }); setMessage({ type: 'success', text: 'Summaries regenerated.' }); } catch { setMessage({ type: 'error', text: 'Network error.' }); } finally { setRegenerating(false); } }; const messageStyle = (type) => ({ padding: '10px 12px', borderRadius: 8, marginBottom: 16, fontSize: 13, background: type === 'error' ? 'rgba(220,38,38,0.1)' : type === 'success' ? 'rgba(22,163,74,0.1)' : 'var(--bg-secondary)', color: type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : 'var(--text-secondary)', border: `1px solid ${ type === 'error' ? 'rgba(220,38,38,0.3)' : type === 'success' ? 'rgba(22,163,74,0.3)' : 'var(--border-primary)' }`, }); const formatKey = (key) => (key || '') .replace(/_/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()); const renderFactRow = (fact, { inferred = false } = {}) => { const isBusy = busyId === fact.id; const isEditing = editingId === fact.id; return (
{formatKey(fact.key)} {fact.category ? ( · {fact.category} ) : null}
{isEditing ? (