memory view in chat client
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
167
packages/chat-client/src/components/MemoryView.jsx
Normal file
167
packages/chat-client/src/components/MemoryView.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user