194 lines
7.7 KiB
JavaScript
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>
|
|
);
|
|
} |