Files
nexusAI/packages/chat-client/src/components/MemoryView.jsx
2026-04-17 22:45:24 -07:00

194 lines
7.7 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { getEpisodes, deleteEpisode } from '../api/orchestration';
import ReactMarkdown from 'react-markdown';
const PAGE_SIZE = 20;
export default function MemoryView({ onNavigate }) {
const [episodes, setEpisodes] = useState([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [query, setQuery] = useState(''); // committed search term
const [expanded, setExpanded] = useState(null); // episode id
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getEpisodes({ limit: PAGE_SIZE, offset, q: query || undefined });
setEpisodes(data.episodes);
setTotal(data.total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [offset, query]);
useEffect(() => { load(); }, [load]);
function handleSearch(e) {
e.preventDefault();
setOffset(0); // reset to page 1 on new search
setQuery(search);
}
async function handleDelete(id) {
if (!confirm('Delete this memory? This cannot be undone.')) return;
await deleteEpisode(id);
load();
}
const totalPages = Math.ceil(total / PAGE_SIZE);
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', background: 'var(--bg-base)' }}>
{/* Header */}
<div className="panel-header" style={{ padding: '0 24px', gap: 12 }}>
<button className="btn-icon" onClick={() => onNavigate('settings')} title="Back to Settings">
</button>
<span className="text-base" style={{ fontWeight: 500 }}>Memory Viewer</span>
<span className="text-sm text-muted" style={{ marginLeft: 'auto' }}>
{total} episode{total !== 1 ? 's' : ''}
</span>
</div>
{/* Search bar */}
<form onSubmit={handleSearch} style={{ padding: '12px 24px', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
className="text-sm"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search memories…"
style={{
flex: 1, padding: '8px 12px',
background: 'var(--bg-surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', color: 'var(--text-primary)',
}}
/>
<button type="submit" className="btn-primary" style={{ padding: '8px 16px' }}>
Search
</button>
{query && (
<button type="button" className="btn-icon" onClick={() => { setSearch(''); setQuery(''); setOffset(0); }}>
</button>
)}
</div>
</form>
{/* Episode list */}
<div className="scroll-y flex-1" style={{ padding: '16px 24px' }}>
{loading && <p className="text-sm text-muted">Loading</p>}
{error && <p className="text-sm" style={{ color: 'var(--error, #e05)' }}>{error}</p>}
{!loading && episodes.length === 0 && (
<p className="text-sm text-muted">No memories found.</p>
)}
{episodes.map(ep => (
<EpisodeCard
key={ep.id}
episode={ep}
expanded={expanded === ep.id}
onToggle={() => setExpanded(expanded === ep.id ? null : ep.id)}
onDelete={() => handleDelete(ep.id)}
/>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 12, padding: '12px', borderTop: '1px solid var(--border)',
}}>
<button className="btn-icon" disabled={offset === 0}
onClick={() => setOffset(o => Math.max(0, o - PAGE_SIZE))}></button>
<span className="text-sm text-muted">{currentPage} / {totalPages}</span>
<button className="btn-icon" disabled={currentPage >= totalPages}
onClick={() => setOffset(o => o + PAGE_SIZE)}></button>
</div>
)}
</div>
);
}
function stripMarkdown(text) {
return text
.replace(/\*\*(.*?)\*\*/g, '$1') // bold
.replace(/\*(.*?)\*/g, '$1') // italic
.replace(/`([^`]+)`/g, '$1') // inline code
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/^\s*[-*+]\s+/gm, '') // list markers
.trim();
}
function EpisodeCard({ episode, expanded, onToggle, onDelete }) {
const date = new Date(episode.created_at * 1000).toLocaleString();
const preview = stripMarkdown(episode.user_message).slice(0, 80) +
(episode.user_message.length > 80 ? '…' : '');
return (
<div style={{
background: 'var(--bg-surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', marginBottom: 8, overflow: 'hidden',
}}>
{/* Card header — always visible */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 14px', cursor: 'pointer' }}
onClick={onToggle}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)' }}>{preview}</span>
<span className="text-sm text-muted">{date}</span>
<span className="text-muted" style={{ fontSize: 11 }}>#{episode.id}</span>
<button className="btn-icon" style={{ color: 'var(--error, #e05)', fontSize: 14 }}
onClick={e => { e.stopPropagation(); onDelete(); }} title="Delete">🗑</button>
<span className="text-muted" style={{ fontSize: 11 }}>{expanded ? '▲' : '▼'}</span>
</div>
{/* Expanded content */}
{expanded && (
<div style={{ padding: '0 14px 14px', borderTop: '1px solid var(--border)' }}>
<MessageBlock label="You" content={episode.user_message} color="var(--accent)" />
<MessageBlock label="NexusAI" content={episode.ai_response} color="var(--text-secondary)" />
{episode.token_count > 0 && (
<p className="text-sm text-muted" style={{ marginTop: 8 }}>
Tokens: {episode.token_count}
</p>
)}
</div>
)}
</div>
);
}
function MessageBlock({ label, content, color }) {
const isAI = label === 'NexusAI';
return (
<div style={{ marginTop: 12 }}>
<p style={{ fontSize: 11, fontWeight: 600, color, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</p>
<ReactMarkdown
components={{
p: ({children}) => <p style={{ margin: '0 0 8px', lineHeight: 1.6, fontSize: 13 }}>{children}</p>,
ul: ({children}) => <ul style={{ margin: '0 0 8px', paddingLeft: '20px' }}>{children}</ul>,
ol: ({children}) => <ol style={{ margin: '0 0 8px', paddingLeft: '20px' }}>{children}</ol>,
li: ({children}) => <li style={{ marginBottom: '2px', fontSize: 13 }}>{children}</li>,
code: ({inline, children}) => inline
? <code style={{ background: 'var(--bg-elevated)', padding: '1px 5px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontFamily: 'monospace' }}>{children}</code>
: <pre style={{ background: 'var(--bg-elevated)', padding: '10px 12px', borderRadius: 'var(--radius-md)', overflowX: 'auto', fontSize: 12, fontFamily: 'monospace' }}><code>{children}</code></pre>,
strong: ({children}) => <strong style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{children}</strong>,
}}
>{content}</ReactMarkdown>
</div>
);
}