memory view in chat client

This commit is contained in:
Storme-bit
2026-04-17 19:50:13 -07:00
parent 05f1fbb04e
commit b3fb936494
3 changed files with 187 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import AllChatsView from './components/AllChatsView';
import AllProjectsView from './components/AllProjectsView';
import SettingsView from './components/SettingsView';
import ProjectView from './components/ProjectView';
import MemoryView from './components/MemoryView';
/**** useHooks **** */
import { useSession } from './hooks/useSession';
@@ -125,6 +126,8 @@ export default function App() {
/>
)}
{view === 'memory' && <MemoryView onNavigate={setView} />}
<InfoPanel
isOpen={rightOpen}

View File

@@ -220,3 +220,20 @@ export async function updateSessionProject(sessionId, projectId) {
if (!res.ok) throw new Error(`Failed to update session project: ${res.status}`);
return res.json();
}
export async function getEpisodes({ limit = 50, offset = 0, sessionId, q } = {}) {
const url = new URL(`${BASE}/episodes`);
url.searchParams.set('limit', limit);
url.searchParams.set('offset', offset);
if (sessionId) url.searchParams.set('sessionId', sessionId);
if (q) url.searchParams.set('q', q);
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`Failed to fetch episodes: ${res.status}`);
return res.json(); // { episodes, total }
}
export async function deleteEpisode(id) {
const res = await fetch(`${BASE}/episodes/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Failed to delete episode: ${res.status}`);
}

View File

@@ -0,0 +1,167 @@
import React, { useState, useEffect, useCallback } from 'react';
import { getEpisodes, deleteEpisode } from '../api/orchestration';
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 EpisodeCard({ episode, expanded, onToggle, onDelete }) {
const date = new Date(episode.created_at * 1000).toLocaleString();
const preview = 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 }) {
return (
<div style={{ marginTop: 12 }}>
<p style={{ fontSize: 11, fontWeight: 600, color, marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{label}
</p>
<p className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{content}</p>
</div>
);
}