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 AllProjectsView from './components/AllProjectsView';
|
||||||
import SettingsView from './components/SettingsView';
|
import SettingsView from './components/SettingsView';
|
||||||
import ProjectView from './components/ProjectView';
|
import ProjectView from './components/ProjectView';
|
||||||
|
import MemoryView from './components/MemoryView';
|
||||||
|
|
||||||
/**** useHooks **** */
|
/**** useHooks **** */
|
||||||
import { useSession } from './hooks/useSession';
|
import { useSession } from './hooks/useSession';
|
||||||
@@ -124,6 +125,8 @@ export default function App() {
|
|||||||
onNewProjectChat={handleNewProjectChat}
|
onNewProjectChat={handleNewProjectChat}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'memory' && <MemoryView onNavigate={setView} />}
|
||||||
|
|
||||||
|
|
||||||
<InfoPanel
|
<InfoPanel
|
||||||
|
|||||||
@@ -219,4 +219,21 @@ export async function updateSessionProject(sessionId, projectId) {
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to update session project: ${res.status}`);
|
if (!res.ok) throw new Error(`Failed to update session project: ${res.status}`);
|
||||||
return res.json();
|
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