summaries chat client

This commit is contained in:
Storme-bit
2026-04-21 02:42:18 -07:00
parent 21a7e5f3b5
commit 01f35b7b82
9 changed files with 227 additions and 3 deletions

View File

@@ -12,6 +12,7 @@ import AllProjectsView from './components/AllProjectsView';
import SettingsView from './components/SettingsView';
import ProjectView from './components/ProjectView';
import MemoryView from './components/MemoryView';
import SummaryView from './components/SummaryView';
/**** useHooks **** */
import { useSession } from './hooks/useSession';
@@ -27,6 +28,7 @@ const BACK_MAP = {
'settings': 'home',
'project': 'all-projects',
'memory': 'settings',
'summaries': 'chat',
};
export default function App() {
@@ -37,6 +39,7 @@ export default function App() {
const [viewHistory, setViewHistory] = useState([]);
const [activeProject, setActiveProject] = useState(null);
const { projects, refreshProjects } = useProjects();
const [summarising, setSummarising] = useState(false);
// Lifted model props — available to header + SettingsView
const [modelProps, setModelProps] = useState(null);
@@ -159,6 +162,7 @@ export default function App() {
onBack={goBack}
canGoBack={canGoBack}
loadedModel={modelProps?.modelAlias ?? null}
summarising={summarising}
/>
)}
@@ -205,6 +209,13 @@ export default function App() {
/>
)}
{view === 'summaries' && (
<SummaryView
activeSession={activeSession}
onBack={goBack}
/>
)}
<InfoPanel
isOpen={rightOpen}
onToggle={() => setRightOpen(o => !o)}
@@ -214,6 +225,8 @@ export default function App() {
onModelChange={setSelectedModel}
lastModel={lastModel}
lastTokenCount={lastTokenCount}
summarising={summarising}
onViewSummary={() => navigate('summaries')}
/>
</div>
);

View File

@@ -205,3 +205,10 @@ export async function getModelProps() {
if (!res.ok) throw new Error('Failed to fetch model props');
return res.json();
}
export async function fetchSessionSummaries(sessionId) {
const res = await fetch(`${BASE_URL}/summaries/session/${sessionId}`);
if (!res.ok) throw new Error(`Failed to fetch summaries: ${res.status}`);
return res.json();
}

View File

@@ -12,6 +12,7 @@ export default function ChatWindow({
onBack,
canGoBack,
loadedModel,
summarising,
}) {
const bottomRef = useRef(null);
const inputRef = useRef(null);
@@ -86,6 +87,20 @@ export default function ChatWindow({
No model loaded
</span>
)}
{summarising && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<div style={{
width: '10px', height: '10px', borderRadius: '50%',
border: '2px solid var(--accent)',
borderTopColor: 'transparent',
animation: 'spin 0.7s linear infinite',
flexShrink: 0,
}} />
<span style={{ fontSize: '11px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
Summarising
</span>
</div>
)}
<button className="btn-icon" onClick={onTogglePanel} title="Session info"></button>
</div>
</div>

View File

@@ -1,6 +1,17 @@
import React from 'react';
export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel, lastTokenCount, selectedModel, onModelChange, models }) {
export default function InfoPanel({
isOpen,
onToggle,
activeSession,
lastModel,
lastTokenCount,
selectedModel,
onModelChange,
models,
summarising,
onViewSummary,
}) {
return (
<div className="flex-col" style={{
position: 'fixed',
@@ -74,6 +85,37 @@ export default function InfoPanel({ isOpen, onToggle, activeSession, lastModel,
)}
</Section>
{/* Session Memory button */}
{activeSession && !activeSession.isNew && (
<button
onClick={onViewSummary}
className="btn-reset text-sm"
style={{
marginTop: '8px', width: '100%', padding: '7px 10px',
borderRadius: 'var(--radius-md)',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
color: 'var(--text-secondary)',
display: 'flex', alignItems: 'center', gap: '8px',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--accent-hover)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border)'}
>
<span></span>
<span>Session Memory</span>
{summarising && (
<div style={{
marginLeft: 'auto',
width: '8px', height: '8px', borderRadius: '50%',
border: '2px solid var(--accent-hover)',
borderTopColor: 'transparent',
animation: 'spin 0.7s linear infinite',
flexShrink: 0,
}} />
)}
</button>
)}
</div>
)}
</div>

View File

@@ -0,0 +1,124 @@
import React, { useState, useEffect } from 'react';
import { fetchSessionSummaries } from '../api/orchestration';
import ReactMarkdown from 'react-markdown';
export default function SummaryView({ activeSession, onBack }) {
const [summaries, setSummaries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [expanded, setExpanded] = useState(null);
useEffect(() => {
if (!activeSession || activeSession.isNew) {
setLoading(false);
return;
}
setLoading(true);
fetchSessionSummaries(activeSession.external_id)
.then(data => setSummaries(Array.isArray(data) ? data : []))
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [activeSession]);
function formatTimestamp(ts) {
if (!ts) return '—';
return new Date(ts * 1000).toLocaleString([], {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
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={onBack}></button>
<span className="text-base" style={{ fontWeight: 500 }}>Session Memory</span>
<span className="text-sm text-muted" style={{ marginLeft: 'auto' }}>
{summaries.length} summar{summaries.length !== 1 ? 'ies' : 'y'}
</span>
</div>
{/* Session name pill */}
{activeSession && (
<div style={{ padding: '8px 24px 0' }}>
<span className="text-xs text-muted" style={{
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: '999px',
padding: '3px 10px',
}}>
{activeSession.name || activeSession.external_id}
</span>
</div>
)}
{/* Content */}
<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 && !activeSession && (
<p className="text-sm text-muted">No active session.</p>
)}
{!loading && activeSession && summaries.length === 0 && (
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: '12px', padding: '48px 0', color: 'var(--text-muted)',
}}>
<span style={{ fontSize: '28px', opacity: 0.3 }}></span>
<p className="text-sm">No summaries yet for this session.</p>
<p className="text-xs text-muted" style={{ maxWidth: '280px', textAlign: 'center', lineHeight: 1.6 }}>
Summaries generate automatically once a session accumulates enough conversation.
</p>
</div>
)}
{summaries.map(summary => (
<div key={summary.id} style={{
background: 'var(--bg-surface)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)',
marginBottom: '10px', overflow: 'hidden',
}}>
{/* Card header */}
<div
onClick={() => setExpanded(expanded === summary.id ? null : summary.id)}
style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 14px', cursor: 'pointer' }}
>
<span style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)' }}>
Episodes {summary.episode_range}
</span>
<span className="text-xs text-muted">{formatTimestamp(summary.created_at)}</span>
<span className="text-muted" style={{ fontSize: 11 }}>
{expanded === summary.id ? '▲' : '▼'}
</span>
</div>
{/* Expanded content */}
{expanded === summary.id && (
<div style={{ padding: '0 14px 14px', borderTop: '1px solid var(--border)' }}>
<ReactMarkdown components={{
p: ({ children }) => (
<p style={{ margin: '8px 0', lineHeight: 1.7, fontSize: 13, color: 'var(--text-secondary)' }}>
{children}
</p>
),
}}>
{summary.content}
</ReactMarkdown>
{summary.token_count > 0 && (
<p className="text-xs text-muted" style={{ marginTop: 8 }}>
{summary.token_count.toLocaleString()} tokens covered
</p>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { streamMessage, updateSession } from '../api/orchestration';
export function useChat({ activeSession, appendMessage, updateLastMessage, refreshSessions }) {
@@ -7,6 +7,18 @@ export function useChat({ activeSession, appendMessage, updateLastMessage, refre
const [lastTokenCount, setLastTokenCount] = useState(0);
const [lastModel, setLastModel] = useState(null);
const cancelRef = useRef(null);
const prevStreaming = React.useRef(false);
const [summarising, setSummarising] = useState(false);
useEffect(() => {
if (prevStreaming.current && !streaming) {
// Stream just finished — trigger the summarising indicator
setSummarising(true);
const t = setTimeout(() => setSummarising(false), 8000);
return () => clearTimeout(t);
}
prevStreaming.current = streaming;
}, [streaming]);
const sendMessage = useCallback(async (text, model, projectId = null, session=null) => {
const targetSession = session ?? activeSession;

View File

@@ -35,6 +35,10 @@ html, body, #root {
50% { opacity: 0; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Layout ─────────────────────────────────────────── */
.flex { display: flex; }

View File

@@ -7,6 +7,7 @@ const EXTRACTION_MODEL = getEnv('EXTRACTION_MODEL', 'qwen2.5:3b');
const EMBEDDING_SERVICE_URL = getEnv('EMBEDDING_SERVICE_URL', SERVICES.EMBEDDING_URL);
const ENTITY_TYPES = ['person', 'place', 'project', 'technology', 'concept', 'organization'];
const IGNORED_NAMES = ['good morning', 'good night', 'hello', 'goodbye', 'thanks', 'thank you'];
function buildExtractionPrompt(userMessage, aiResponse, knownEntities = []) {
const knownBlock = knownEntities.length > 0
@@ -101,8 +102,12 @@ async function extractAndStoreEntities(userMessage, aiResponse, projectId=null)
if (!Array.isArray(entities)) throw new Error('Response was not a JSON array');
let saved = 0;
for (const { name, type, notes } of entities) {
if (!name || !type || !ENTITY_TYPES.includes(type)) continue;
if (IGNORED_NAMES.includes(name.toLowerCase())) continue;
const entity = upsertEntity(name, type, notes ?? null);
console.log('[entities] Upserted entity:', entity);

View File

@@ -72,6 +72,8 @@ async function maybeSummarize(session, allEpisodes) {
console.log('[summarization] fetching existing summaries...');
// 2. Fetch existing summaries for session
const summariesRes = await fetch(`${MEMORY_URL}/sessions/${session.id}/summaries`);
console.log('[summarization] memory URL:', MEMORY_URL);
console.log('[summarization] session:', session.id, session.external_id);
console.log('[summarization] summaries fetch status:', summariesRes.status);
if (!summariesRes.ok) return;
const summaries = await summariesRes.json();